netanalyzer/services/webexPhone.js
Joseph McQueen b3c37bd7df feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent
Rebrand NetAnalyzer -> StoreHealthAnalyzer and consolidate the store
reporting surface into a single `st [number]` command with focused
sub-modes.

Commands
- st [number]                - general info (SIW + brands + Meraki net link)
- st [number] network        - switches, APs, store server
- st [number] pos            - registers, payment terminals, customer display
- st [number] ios            - MDM-tracked iOS hardware
- st [number] phone          - wired 78xx + DECT basestations/handsets with
                               registration state, extensions and main DID
- st [number] av             - Atlas AMPs + MDM-tracked Apple TVs, video
                               walls, music players, LED displays
- Removed `analyze` in favor of the unified `st` surface

Integrations
- integrations/webex: Service App OAuth with rotating refresh tokens,
  seed + cleanup scripts, tokens/ storage (git-ignored)
- integrations/atlas: Xyte client + cached device discovery keyed on
  zero-padded 6-digit store numbers, cold-cache failure -> unavailable
  banner instead of a misleading empty result
- services/webexPhone, services/webexService, services/avService: shape
  raw upstream data into the report layer's contract
- utils/merakiMatcher: FQDN hostname extraction so payment terminals
  match Meraki descriptions; case-insensitive lookup
- utils/chunkReport: split long markdown replies at 7000-char boundaries

Reliability / ops
- server.js: awaited framework.stop() + 8s hard-kill timer so nodemon /
  Docker restarts don't leak WDM device registrations ("excessive device
  registrations")
- nodemon.json: SIGINT so the graceful path always runs
- scripts/cleanupWebexDevices.js: one-shot WDM cleanup utility
- Group-space routing: hears() regexes tolerate the leading @BotName
  prefix Webex prepends to mentions
- Replaced HTML-unsafe <number> placeholders with [number] in all help
  strings

Remote agent containerization
- docker/remote-agent/: multi-stage node:22-alpine image, non-root user,
  tini for signal handling, minimal deps (ws/axios/dotenv)
- docker/remote-agent/package.sh: docker buildx build defaulting to
  linux/amd64 (with override), saves image + assembles deploy/ + writes
  SHA256 + zips for offline transfer
- docker/remote-agent/deploy/: runtime docker-compose.yml, install.sh
  with platform sanity check, remote-host README
- .dockerignore + .gitignore updates for build artifacts and dist bundles
- npm run agent:package convenience script

Cleanup
- Dropped storeHealth.js / HealthReport.js and their tests/mocks in favor
  of the shared storeDetail pipeline
- Store model handles null SIW records gracefully; toSummary always
  ends with a newline so the Meraki link sits on its own line

Tests
- 144 tests across 14 suites passing; new coverage for atlasClient,
  atlasDevices, avService, avCategory classification, webexPhone,
  webexServiceAppAuth, storeDetail integration, siw, chunkReport and
  the updated meraki matcher

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 09:54:41 -04:00

328 lines
10 KiB
JavaScript

/**
* Webex phone-discovery service.
*
* Focused subset of the collabFinder phoneService.js:
* - resolves a store number to its `ae<5digit>@ae.com` person
* - lists the wired desk phones registered to that person (filtered to
* the two Cisco IP Phones in store use: 7821, 7841)
* - lists the person's DECT network and its basestations + handsets
* (handsets carry baseStationId so the caller can group them under
* their parent base)
* - fetches the store's main DID number (callingLineId on the DECT
* network's location)
*
* The shape returned is intentionally flat so renderers in
* integrations/storeDetail.js can iterate without diving through nested
* status wrappers. Graceful-degradation contract: any unrecoverable failure
* (e.g. Service App not configured, tokens missing) yields
* `{ unavailable: true, reason }` rather than throwing, so the bot can
* print a single warning banner the same way it does for SIW.
*/
const webex = require('./webexService');
const logger = require('../utils/logger');
// Cisco IP Phone 7821 / 7841 — the only wired desk phones we care about in
// store mode. Pattern is intentionally loose (some product strings use
// "Cisco 7841", others "CP-7841-K9", etc.).
const WIRED_PHONE_PATTERN = /78(21|41)/;
function storeEmail(storeNumber) {
const padded = String(storeNumber).trim().padStart(5, '0');
return `ae${padded}@ae.com`;
}
async function getPersonIdByEmail(email) {
if (!email) return null;
try {
const res = await webex.request('GET', 'people', null, { email });
const items = res.items || [];
if (items.length === 0) {
logger.warn('Webex person lookup empty', { email });
return null;
}
return items[0].id;
} catch (err) {
logger.error('Webex person lookup failed', { email, error: err.message });
return null;
}
}
/**
* Pull the phone extension assigned to this person. Webex Calling exposes it
* either as a top-level `extension` field on the person, or in `phoneNumbers`
* with type `work_extension`. Returns null if neither is present.
*/
async function getPersonExtension(personId) {
if (!personId) return null;
try {
const person = await webex.request('GET', `people/${personId}`);
if (person?.extension) return String(person.extension);
const fromNumbers = (person?.phoneNumbers || []).find(p =>
String(p.type || '')
.toLowerCase()
.includes('extension')
);
return fromNumbers?.value ? String(fromNumbers.value) : null;
} catch (err) {
logger.warn('Webex person extension lookup failed', {
personId,
error: err.message,
});
return null;
}
}
async function getDevicesForPerson(personId) {
if (!personId) return [];
const all = [];
let next = null;
try {
do {
const params = { personId, max: 100 };
if (next) params.next = next;
const res = await webex.request('GET', 'devices', null, params);
const items = res.items || [];
all.push(...items);
next = res.next || null;
} while (next);
return all;
} catch (err) {
logger.error('Webex device list failed', { personId, error: err.message });
return [];
}
}
async function getDectNetworksForPerson(personId) {
if (!personId) return [];
try {
const res = await webex.request('GET', `telephony/config/people/${personId}/dectNetworks`);
const networks = res.dectNetworks || [];
return networks.map(net => ({
id: net.id,
name: net.name || 'Unknown',
handsetsCount: net.numberOfHandsetsAssigned || 0,
locationName: net.location?.name || null,
locationId: net.location?.id || null,
}));
} catch (err) {
logger.error('Webex DECT networks lookup failed', { personId, error: err.message });
return [];
}
}
async function getDectBasestations(locationId, networkId) {
if (!locationId || !networkId) return [];
try {
const res = await webex.request(
'GET',
`telephony/config/locations/${locationId}/dectNetworks/${networkId}/baseStations`
);
const items = res.items || res.baseStations || [];
return items.map(b => ({
id: b.id,
mac: b.mac || b.macAddress || b.baseMac || null,
name: b.displayName || `Basestation ${b.mac || b.macAddress || 'Unknown'}`,
status: b.status || 'unknown',
lastSeen: b.lastSeen || null,
firmware: b.softwareVersion || null,
model: b.model || null,
ipAddress: b.ip || null,
linesRegistered: b.numberOfLinesRegistered || 0,
}));
} catch (err) {
logger.error('Webex DECT basestations lookup failed', {
locationId,
networkId,
error: err.message,
});
return [];
}
}
async function getDectHandsets(locationId, networkId) {
if (!locationId || !networkId) return [];
try {
const res = await webex.request(
'GET',
`telephony/config/locations/${locationId}/dectNetworks/${networkId}/handsets`
);
const items = res.items || res.handsets || [];
return items.map(h => ({
id: h.id,
// The handset's slot index in the DECT network (1, 2, 3, ...). Used by
// the renderer to compose the "<index>-<extension>" display name.
index: h.index ?? null,
name: h.defaultDisplayName || h.displayName || `Handset ${h.index || ''}`,
status: h.status || 'unknown',
lastSeen: h.lastSeen || null,
mac: h.mac || null,
firmware: h.softwareVersion || null,
model: h.model || null,
extension: h.accessCode || h.lines?.[0]?.esn || null,
// baseStationId is only present on the detail endpoint; the per-handset
// detail fetch below fills it in.
baseStationId: h.baseStationId || null,
}));
} catch (err) {
logger.error('Webex DECT handsets lookup failed', {
locationId,
networkId,
error: err.message,
});
return [];
}
}
async function getDectHandsetDetail(locationId, networkId, handsetId) {
if (!locationId || !networkId || !handsetId) return null;
try {
const h = await webex.request(
'GET',
`telephony/config/locations/${locationId}/dectNetworks/${networkId}/handsets/${handsetId}`
);
return {
id: h.id,
index: h.index ?? null,
baseStationId: h.baseStationId || null,
lastRegistrationTime: h.lines?.[0]?.lastRegistrationTime || null,
extension: h.lines?.[0]?.extension || null,
};
} catch (err) {
logger.warn('Webex DECT handset detail failed', {
handsetId,
error: err.message,
});
return null;
}
}
async function getLocationMainNumber(locationId) {
if (!locationId) return null;
try {
const loc = await webex.request('GET', `telephony/config/locations/${locationId}`);
return loc?.callingLineId?.phoneNumber || loc?.phoneNumber || null;
} catch (err) {
logger.warn('Webex location main-number lookup failed', {
locationId,
error: err.message,
});
return null;
}
}
function shapeWiredPhone(dev, extension = null) {
return {
mac: dev.mac || null,
name: dev.displayName || dev.product || 'Unknown Phone',
model: dev.product || dev.model || null,
firmware: dev.software || dev.softwareVersion || null,
status: dev.connectionStatus || dev.status || 'unknown',
lastSeen: dev.lastSeen || null,
ipAddress: dev.ip || dev.ipAddress || null,
// All wired phones in store mode belong to the store service-account
// person, so they share that person's primary extension. Worth surfacing
// because the device itself doesn't carry it.
extension,
};
}
/**
* Collect every phone artefact we render for a store. Returns
* `{ unavailable: true, reason }` if the Service App is not configured or
* the bootstrap tokens file is missing — callers should surface the reason
* as a banner rather than treating it as an error.
*/
async function collectPhoneStatus(storeNumber) {
const email = storeEmail(storeNumber);
logger.debug('collectPhoneStatus start', { storeNumber, email });
let personId;
try {
personId = await getPersonIdByEmail(email);
} catch (err) {
// getPersonIdByEmail catches its own errors so this only fires when
// request setup fails (e.g. missing client id / missing tokens file).
return { unavailable: true, reason: err.message };
}
if (!personId) {
return {
unavailable: true,
reason:
`No Webex person found for ${email}. ` +
'Confirm the store has a service account provisioned in Webex.',
};
}
const [devicesRes, networksRes, extensionRes] = await Promise.allSettled([
getDevicesForPerson(personId),
getDectNetworksForPerson(personId),
getPersonExtension(personId),
]);
const allDevices = devicesRes.status === 'fulfilled' ? devicesRes.value : [];
const dectNetworks = networksRes.status === 'fulfilled' ? networksRes.value : [];
const dectNetwork = dectNetworks[0] || null;
const personExtension = extensionRes.status === 'fulfilled' ? extensionRes.value : null;
const wiredPhones = allDevices
.filter(d => WIRED_PHONE_PATTERN.test(String(d.product || d.model || '')))
.map(d => shapeWiredPhone(d, personExtension));
let basestations = [];
let handsets = [];
let locationMainNumber = null;
if (dectNetwork?.locationId && dectNetwork?.id) {
const [basesRes, handsetsRes, mainNumRes] = await Promise.allSettled([
getDectBasestations(dectNetwork.locationId, dectNetwork.id),
getDectHandsets(dectNetwork.locationId, dectNetwork.id),
getLocationMainNumber(dectNetwork.locationId),
]);
basestations = basesRes.status === 'fulfilled' ? basesRes.value : [];
const rawHandsets = handsetsRes.status === 'fulfilled' ? handsetsRes.value : [];
locationMainNumber = mainNumRes.status === 'fulfilled' ? mainNumRes.value : null;
// Per-handset detail pulls in baseStationId / lastRegistrationTime so we
// can group handsets under their parent basestation.
handsets = await Promise.all(
rawHandsets.map(async h => {
const detail = await getDectHandsetDetail(dectNetwork.locationId, dectNetwork.id, h.id);
return { ...h, ...(detail || {}) };
})
);
}
logger.info('collectPhoneStatus done', {
storeNumber,
wired: wiredPhones.length,
bases: basestations.length,
handsets: handsets.length,
});
return {
phones: wiredPhones,
basestations,
handsets,
dectNetwork,
locationMainNumber,
};
}
module.exports = {
collectPhoneStatus,
// Exposed for unit tests:
storeEmail,
WIRED_PHONE_PATTERN,
getPersonIdByEmail,
getPersonExtension,
getDevicesForPerson,
getDectNetworksForPerson,
getDectBasestations,
getDectHandsets,
getDectHandsetDetail,
getLocationMainNumber,
};