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).
167 lines
7.2 KiB
JavaScript
167 lines
7.2 KiB
JavaScript
// 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 2–4 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);
|
||
}
|