collabSupport/commands/phoneStatus.js
Joseph McQueen 96b26a5aca DECT relay Phase 1: WSS hub + agent + /phonestatus follow-up
The bot runs in the public cloud and can't reach the 10.x/8 network
where DBS-210 bases live. This phase adds a data-center-resident relay
agent that dials outbound over WSS to the bot, and lets /phonestatus
post a follow-up message with per-base health after its main output
has already shipped.

Bot side (services/):
- dectRelayHub.js: WebSocket upgrade handler on /dect-relay/ws with
  bearer-token auth (constant-time compare, header + Sec-WebSocket-
  Protocol fallback for header-stripping proxies). Promise-based RPC
  API with per-call timeouts, mid-flight-disconnect rejection, and
  clean replacement of a stale agent socket when a newer one connects.
- dectDiscovery.js: pure filter that turns a phoneService result into
  a list of reachable bases. Enforces the "must be on 10.0.0.0/8"
  guardrail per requirements, dedups by IP + MAC, prefers Meraki-live
  IP over Webex-cached IP.
- dectCollectorService.js: fan-out layer over the hub. collectAll()
  runs one RPC per base in parallel with per-base error isolation —
  one bad base never fails the batch.

Phone-status integration:
- Renderer gets a dectFollowUpBaseCount opt that emits an italic
  "diagnostics loading for N base(s)..." hint inside the DECT section
  of the main message.
- New exported renderDectDiagnosticsMarkdown() renders the follow-up
  message: healthy/warning icon per base, uptime + firmware summary,
  structured Power Loss reboot line, and per-base failure hints (e.g.
  "relay accepted the request but the base did not respond in time").
- commands/phoneStatus.js discovers reachable bases synchronously
  (pure), sends the main message, then fires collectAll() and posts
  the follow-up as a separate message. Failures logged, never thrown
  back to the user.
- Chat only: HTTP callers keep their single-message contract.

Agent side (dect-relay-agent/):
- Standalone Node process with its own package.json (only ws, axios,
  dotenv). Reuses the shared integrations/cisco-dect/{client,probes,
  statusXml}.js modules from the parent workspace so there's no code
  duplication.
- Auto-reconnect with exponential backoff + jitter.
- Dispatches collect / reboot / force-reboot / reboot-chain /
  force-reboot-chain / factory-reset / reconfigure-tree.
- DECT admin credentials live ONLY on the agent (never on the bot).
  Shared bearer token gates the WSS handshake.
- README.md covers install, config, wire protocol, and safety model.

Env / infra:
- .env.example: adds DECT_RELAY_AGENT_TOKEN + optional DECT_RELAY_PATH
  and DECT_COLLECT_TIMEOUT_MS. Reframes DECT_TEST_* as the local-dev
  test harness rather than the production path.
- index.js: captures the http.Server from app.listen() and attaches
  the relay hub when DECT_RELAY_AGENT_TOKEN is set; graceful shutdown
  now closes the hub so in-flight RPCs get rejected cleanly.
- Adds "ws" to bot dependencies.

Tests (99 -> 113):
- tests/dectDiscovery.test.js: 13 cases covering the 10.x guardrail,
  MAC normalization, IP source preference, dedup, and warning shape.
- tests/dectRelayHub.test.js: 14 integration cases using a real
  ws pair on an ephemeral 127.0.0.1 port — auth (missing / wrong /
  correct via header / correct via protocol fallback), hello frame,
  RPC round-trip with correlation, agent error surfacing, concurrent
  out-of-order replies, timeout, mid-flight disconnect, replacement
  of a stale socket, and execAction routing.
- tests/renderers.test.js: 8 new cases for the DECT-follow-up loading
  hint (plural / singular / off) and the diagnostics renderer (empty,
  healthy, warning, power-loss dedup, active RTP, error hint, footer).
2026-07-02 17:03:32 -04:00

167 lines
7.2 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.

// src/commands/phoneStatus.js
//
// Chat + HTTP entry point for /phonestatus. The heavy rendering lives in
// services/renderers/phoneStatusRenderer.js so the Jira poller can emit
// the same markdown (see services/jiraPollerService.js). This handler
// stays thin: parse args, call the collector, hand data to the renderer,
// respond.
import { randomUUID } from 'node:crypto';
import { collectPhoneStatus } from '../services/phoneService.js';
import {
renderPhoneStatusMarkdown,
renderDectDiagnosticsMarkdown,
} from '../services/renderers/phoneStatusRenderer.js';
import { buildIgmpFixCard } from './igmpFix.js';
import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js';
import { extractRequester } from '../utils/requester.js';
import { logger } from '../utils/logger.js';
import { discoverDectBases } from '../services/dectDiscovery.js';
import { collectAll } from '../services/dectCollectorService.js';
export async function handlePhoneStatus(bot, trigger) {
logger('phone:status', 'Handler entered', 'debug');
// Support both Webex (args) and HTTP (query) calls
const query = trigger.query || {};
const args = trigger.args || [];
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
const isDetailed = (args[1]?.toLowerCase() === 'detailed') ||
(query.mode === 'detailed') ||
(query.detailed === 'true' || query.detailed === true);
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
const errorMsg = 'Please provide a 24 digit store number.\n' +
'Example: `/phonestatus 782` (or `detailed` / `?detailed=true` for more; includes Meraki links) or `https://.../phonestatus?storeNum=782`';
await bot.say('markdown', errorMsg);
return;
}
logger('phone:status', `Collecting phone status for store ${storeNum}`, 'debug');
try {
const data = await collectPhoneStatus(storeNum);
if (!data) throw new Error('collectPhoneStatus returned undefined');
// JSON alt-output path (kept in the handler because it bypasses
// markdown rendering entirely — no shared renderer applies).
if (query.format === 'json' || (args[1] && args[1].toLowerCase() === 'json')) {
const jsonPayload = {
store: storeNum,
mainNumber: data.locationMainNumber,
timezone: (data.telephonyProfile && data.telephonyProfile.timeZone) || null,
person: data.person ? { displayName: data.person.displayName, phoneNumbers: data.person.phoneNumbers } : null,
timestamp: new Date().toISOString(),
};
await bot.say('markdown', '```json\n' + JSON.stringify(jsonPayload, null, 2) + '\n```');
return;
}
// Discover reachable DECT bases BEFORE rendering so we can tell
// the renderer how many bases the follow-up will cover. Discovery
// is a pure filter over what phoneService already fetched — no
// network calls, so it doesn't slow the main output. Only chat
// triggers get a follow-up; HTTP callers keep the single-message
// contract they had before.
const dectFollowUpEnabled = !!trigger.person;
const { bases: reachableBases, warnings: discoveryWarnings } = dectFollowUpEnabled
? discoverDectBases(data)
: { bases: [], warnings: [] };
if (discoveryWarnings.length > 0) {
logger(
'phone:status',
`DECT discovery warnings for store ${storeNum}: ${discoveryWarnings.map((w) => w.reason).join('; ')}`,
'warn',
);
}
const reply = renderPhoneStatusMarkdown(data, {
storeNum,
detailed: isDetailed,
footer: true,
dectFollowUpBaseCount: reachableBases.length,
});
await bot.say('markdown', reply || 'No data available.');
// Kick off DECT follow-up. Fire-and-forget from this handler's
// perspective — the awaits inside runDectFollowUp() are just so
// failures get logged with a stable scope, they don't propagate
// back to the user's original /phonestatus call. If the relay is
// offline or a base is unreachable we still post the follow-up
// (with per-base error lines) so the user isn't left wondering
// where the promised diagnostics went.
if (dectFollowUpEnabled && reachableBases.length > 0) {
runDectFollowUp(bot, storeNum, reachableBases).catch((err) => {
logger('phone:status', `DECT follow-up failed for store ${storeNum}: ${err.message}`, 'error');
});
}
// IGMP-snooping remediation card — only when (a) the multicast
// summary flagged deviation AND (b) we know the networkId (can't
// fix what we can't address) AND (c) the invocation came from
// chat, not HTTP. HTTP callers don't have adaptive-card UX; the
// remediation surface for them is a future gated POST endpoint.
// `trigger.person` is populated by the framework for chat triggers
// and absent for HTTP triggers (see index.js command dispatch).
if (data.multicast?.needsFix && data.multicast.networkId && trigger.person) {
const cardId = randomUUID();
const requester = extractRequester(trigger);
pendingIgmpFixes.set(cardId, {
networkId: data.multicast.networkId,
networkName: data.multicast.networkName,
storeNum,
requester,
// Only the summary is stashed. The fix payload is a constant,
// so we don't need the raw snapshot — keeps the pending-store
// memory footprint tiny and avoids the temptation to
// read-then-mutate at PUT time.
summary: {
defaultSnoopOn: data.multicast.defaultSnoopOn,
defaultFloodOff: data.multicast.defaultFloodOff,
deviatingOverrides: data.multicast.deviatingOverrides || [],
},
});
const card = buildIgmpFixCard({
storeNum,
networkName: data.multicast.networkName,
summary: {
defaultSnoopOn: data.multicast.defaultSnoopOn,
defaultFloodOff: data.multicast.defaultFloodOff,
deviatingOverrides: data.multicast.deviatingOverrides || [],
},
cardId,
});
await bot.say({
markdown: `Multicast policy on store ${storeNum}'s network deviates from DECT-safe defaults. Review and confirm:`,
attachments: [{
contentType: 'application/vnd.microsoft.card.adaptive',
content: card,
}],
});
}
} catch (err) {
logger('phone:status', `Error collecting phone status for store ${storeNum}: ${err.message}`, 'error');
await bot.say('markdown', `Error collecting phone status: ${err.message}`);
}
}
/**
* Run the DECT-diagnostics follow-up as a separate message in the
* same room. Only invoked from chat triggers. Errors are logged
* (never thrown up) — the /phonestatus main output has already been
* sent by the time we get here, so a follow-up crash shouldn't leave
* the user with a broken chat experience.
*
* Renderer emits an empty string only when the results list is empty
* — which shouldn't happen because we already checked reachableBases
* .length > 0 at the call site, but we still guard against it here.
*/
async function runDectFollowUp(bot, storeNum, bases) {
const results = await collectAll(bases);
const md = renderDectDiagnosticsMarkdown(results, { storeNum });
if (!md) return;
await bot.say('markdown', md);
}