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).
308 lines
14 KiB
JavaScript
308 lines
14 KiB
JavaScript
// src/services/renderers/phoneStatusRenderer.js
|
|
//
|
|
// Extracted from commands/phoneStatus.js so the same output can drive
|
|
// BOTH the chat reply (`bot.say('markdown', md)`) AND the Jira poller
|
|
// comment (via utils/markdownToAdf). Byte-for-byte identical to what
|
|
// the chat command used to emit for a given input.
|
|
//
|
|
// Options
|
|
// storeNum (required) header string uses it
|
|
// detailed default false — mirrors chat's `?detailed=true` toggle:
|
|
// adds SIP URLs / alt SIPs, more port info, PoE state, etc.
|
|
// footer default true — appends the `*Last checked: HH:MM*`
|
|
// italic line. Chat passes true (unchanged). Poller passes
|
|
// false because a Jira comment already has an authoritative
|
|
// header timestamp and a Jira-side comment `created` field.
|
|
//
|
|
// The renderer is pure (no I/O). All time-derived output goes through
|
|
// `simpleTimeAgo` — the same helper the chat handler used, so relative
|
|
// times ("2h ago") stay consistent across chat and Jira surfaces.
|
|
|
|
import { simpleTimeAgo, formatBytes } from '../../utils/time.js';
|
|
|
|
/**
|
|
* Render a phone-status markdown snapshot from a `collectPhoneStatus`
|
|
* result.
|
|
*
|
|
* @param {object} data collectPhoneStatus() output
|
|
* @param {object} opts
|
|
* @param {string} opts.storeNum 2-6 digit store id (header text)
|
|
* @param {boolean} [opts.detailed=false]
|
|
* @param {boolean} [opts.footer=true]
|
|
* @param {number} [opts.dectFollowUpBaseCount=0]
|
|
* When > 0, emits an "⏳ DECT base data loading for N base(s)…"
|
|
* line inside the DECT Basestations section. Signals to the reader
|
|
* that a follow-up message with base-station diagnostics is on the
|
|
* way. Chat handler passes this after the base count comes back
|
|
* from discoverDectBases(); poller and HTTP callers pass 0.
|
|
* @returns {string} markdown, whitespace-trimmed and ready to send.
|
|
*/
|
|
export function renderPhoneStatusMarkdown(data, opts = {}) {
|
|
const { storeNum, detailed = false, footer = true, dectFollowUpBaseCount = 0 } = opts;
|
|
|
|
let reply = `**Phone Status - Store ${storeNum}**\n\n`;
|
|
|
|
const phones = data.phones?.data || [];
|
|
const dectBasestations = data.dectBasestations || [];
|
|
const dectHandsets = data.dectHandsets || [];
|
|
const dectNet = data.dectNetwork || null;
|
|
|
|
if (phones.length === 0 && dectBasestations.length === 0) {
|
|
reply += 'No phones or DECT basestations found.\n';
|
|
return reply.trim();
|
|
}
|
|
|
|
const prof = data.telephonyProfile || {};
|
|
const pers = data.person || {};
|
|
if (prof.timeZone) {
|
|
reply += `**Timezone:** ${prof.timeZone}\n`;
|
|
}
|
|
if (data.locationMainNumber) {
|
|
let extPart = '';
|
|
if (pers.phoneNumbers && pers.phoneNumbers.length > 0) {
|
|
const nums = pers.phoneNumbers.map(n => n.value || n).filter(Boolean);
|
|
if (nums.length > 0) {
|
|
extPart = ` (${nums.join(', ')})`;
|
|
}
|
|
}
|
|
reply += `**PhoneNumber:** ${data.locationMainNumber}${extPart}\n\n`;
|
|
}
|
|
if (detailed && prof.outgoingPermission) {
|
|
const op = prof.outgoingPermission;
|
|
const mode = op.useCustomEnabled ? 'custom rules' : 'default';
|
|
const ruleCount = op.callingPermissions ? op.callingPermissions.length : 0;
|
|
reply += `Outgoing: ${mode} (${ruleCount} permission entries)\n\n`;
|
|
}
|
|
|
|
// Desk Phones
|
|
if (phones.length > 0) {
|
|
reply += '**Desk Phones:**\n';
|
|
phones.forEach(phone => {
|
|
const lastSeen = simpleTimeAgo(phone.lastSeen) || 'unknown';
|
|
let prefix = '✅ ';
|
|
if (phone.status !== 'connected') prefix = '⚠️ ';
|
|
|
|
reply += `${prefix}**${phone.displayName || phone.name || 'Unknown Phone'}** (${phone.status}) Last seen: ${lastSeen}\n`;
|
|
|
|
const fw = phone.firmware && phone.firmware !== '—' ? ` FW: ${phone.firmware}` : '';
|
|
const ser = phone.serial && phone.serial !== '—' ? ` Serial: ${phone.serial}` : '';
|
|
if (fw || ser) {
|
|
reply += ` ${fw}${ser ? (fw ? ' •' : '') + ser : ''}\n`;
|
|
}
|
|
if (detailed && phone.primarySipUrl && phone.primarySipUrl !== '—') {
|
|
reply += ` SIP: ${phone.primarySipUrl}\n`;
|
|
}
|
|
if (detailed && phone.sipUrls && phone.sipUrls.length > 1) {
|
|
reply += ` Alt SIPs: ${phone.sipUrls.slice(0, 2).join(', ')}${phone.sipUrls.length > 2 ? '…' : ''}\n`;
|
|
}
|
|
if (phone.errorCodes && phone.errorCodes.length > 0) {
|
|
reply += ` ⚠️ Errors: ${phone.errorCodes.join(', ')}\n`;
|
|
}
|
|
|
|
if (phone.meraki && (phone.meraki.port || phone.meraki.switchName)) {
|
|
let mPrefix = '';
|
|
if (phone.meraki.status !== 'Online') mPrefix = '⚠️ ';
|
|
reply += ` •${mPrefix}**${phone.meraki.switchName || 'Unknown Switch'}** (${phone.meraki.status || 'unknown'}) ` +
|
|
`Wired • Port: ${phone.meraki.port || '—'} • VLAN: ${phone.meraki.vlan || '—'} ` +
|
|
`• IP: ${phone.meraki.ip || '—'} LastSeen: ${simpleTimeAgo(phone.meraki.lastSeen)}${phone.meraki.clientUrl ? ` [Meraki↗](${phone.meraki.clientUrl})` : ''}\n`;
|
|
|
|
const u = phone.meraki.usage;
|
|
if (u && (u.sent || u.recv || u.total)) {
|
|
const sent = formatBytes(u.sent || 0);
|
|
const recv = formatBytes(u.recv || 0);
|
|
const tot = u.total ? formatBytes(u.total) : '';
|
|
reply += ` Data (recent): ${sent} sent / ${recv} recv${tot ? ' (total ' + tot + ')' : ''}\n`;
|
|
}
|
|
if (detailed) {
|
|
const poe = phone.meraki.poeEnabled != null ? (phone.meraki.poeEnabled ? 'PoE on' : 'PoE off') : '';
|
|
const spd = phone.meraki.speed ? `${phone.meraki.speed}` : '';
|
|
const pol = phone.meraki.portName ? `port ${phone.meraki.portName}` : '';
|
|
const extras = [poe, spd, pol].filter(Boolean).join(' • ');
|
|
if (extras) reply += ` ${extras}\n`;
|
|
if (phone.meraki.accessPolicy) reply += ` Policy: ${phone.meraki.accessPolicy}\n`;
|
|
}
|
|
} else if (detailed) {
|
|
reply += ` (no recent Meraki client/switch data)\n`;
|
|
}
|
|
reply += '\n';
|
|
});
|
|
}
|
|
|
|
// DECT Basestations
|
|
if (dectBasestations.length > 0) {
|
|
reply += '**DECT Basestations:**\n';
|
|
|
|
// Multicast health signal — DECT relies on multicast for basestation
|
|
// discovery / handset registration handshakes. When IGMP snooping is
|
|
// on without a corresponding querier, or when flood-unknown is off,
|
|
// those frames get dropped and DECT registration silently fails.
|
|
// Only emitted when the summarizer says `needsFix` — the healthy
|
|
// state is common and would just add noise. Silent on fetch errors
|
|
// (summarizer returns needsFix:false + error:...) so a diagnostic
|
|
// bot doesn't itself become a new source of noise.
|
|
if (data.multicast?.needsFix) {
|
|
const mc = data.multicast;
|
|
const parts = [];
|
|
if (mc.defaultSnoopOn) parts.push('IGMP snoop=ON default');
|
|
if (mc.defaultFloodOff) parts.push('flood-unknown=OFF default');
|
|
const overrideCount = mc.deviatingOverrides?.length || 0;
|
|
if (overrideCount > 0) {
|
|
parts.push(`${overrideCount} switch override(s) deviate from DECT-safe defaults`);
|
|
}
|
|
// parts is always non-empty here: needsFix implies at least one deviation
|
|
reply += `**Multicast:** ⚠️ ${parts.join(', ')} — may disrupt DECT\n`;
|
|
}
|
|
|
|
if (dectNet) {
|
|
reply += `**Network:** ${dectNet.name || '—'} (assigned handsets: ${dectNet.handsetsCount || 0})\n`;
|
|
}
|
|
dectBasestations.forEach(base => {
|
|
let prefix = '✅ ';
|
|
if (base.meraki?.status !== 'Online') prefix = '⚠️ ';
|
|
|
|
const lines = base.linesRegistered != null ? ` (lines: ${base.linesRegistered})` : '';
|
|
reply += `${prefix}**Basestation ${base.mac || 'Unknown'}**${lines}\n`;
|
|
|
|
if (base.meraki && (base.meraki.port || base.meraki.switchName)) {
|
|
let mPrefix = '';
|
|
if (base.meraki.status !== 'Online') mPrefix = '⚠️ ';
|
|
reply += ` •${mPrefix}**${base.meraki.switchName || 'Unknown Switch'}** (${base.meraki.status || 'unknown'}) ` +
|
|
`Wired • Port: ${base.meraki.port || '—'} • VLAN: ${base.meraki.vlan || '—'} ` +
|
|
`• IP: ${base.meraki.ip || '—'} LastSeen: ${simpleTimeAgo(base.meraki.lastSeen)}${base.meraki.clientUrl ? ` [Meraki↗](${base.meraki.clientUrl})` : ''}\n`;
|
|
if (detailed && base.meraki.usage) {
|
|
const u = base.meraki.usage;
|
|
reply += ` Data (recent): ${formatBytes(u.sent || 0)} sent / ${formatBytes(u.recv || 0)} recv\n`;
|
|
}
|
|
} else {
|
|
reply += ` (no recent Meraki client/switch data — possibly offline or not attached to this network)\n`;
|
|
}
|
|
|
|
const registeredHandsets = dectHandsets.filter(h => h.baseStationId === base.id);
|
|
if (registeredHandsets.length > 0) {
|
|
registeredHandsets.forEach(h => {
|
|
reply += ` • **${h.index}-${h.name || 'Handset'}** (ext ${h.extension || '—'}) Registered: ${simpleTimeAgo(h.lastRegistrationTime)}\n`;
|
|
});
|
|
} else {
|
|
reply += ` No handsets registered\n`;
|
|
}
|
|
reply += '\n';
|
|
});
|
|
|
|
const unregisteredHandsets = dectHandsets.filter(h => !h.baseStationId);
|
|
if (unregisteredHandsets.length > 0) {
|
|
reply += '**Unregistered Handsets:**\n';
|
|
unregisteredHandsets.forEach(h => {
|
|
reply += ` • **${h.index}-${h.name || 'Handset'}** (ext ${h.extension || '—'}) Registered: ${simpleTimeAgo(h.lastRegistrationTime)}\n`;
|
|
});
|
|
reply += '\n';
|
|
}
|
|
|
|
// DECT relay follow-up notice. Only shown when the caller has
|
|
// told us a follow-up is actually in-flight (chat handler, after
|
|
// discoverDectBases returned a non-empty list). Silent for HTTP /
|
|
// Jira surfaces where a follow-up doesn't happen.
|
|
if (dectFollowUpBaseCount > 0) {
|
|
const n = dectFollowUpBaseCount;
|
|
reply += `_⏳ Base-station diagnostics loading for ${n} base${n === 1 ? '' : 's'} — a follow-up message will arrive shortly._\n\n`;
|
|
}
|
|
}
|
|
|
|
if (detailed) {
|
|
reply += `_Detailed mode — additional fields above (use without ?detailed=true for compact view)_\n`;
|
|
}
|
|
|
|
if (footer) {
|
|
reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`;
|
|
}
|
|
|
|
return reply.trim();
|
|
}
|
|
|
|
// ─── DECT base-station diagnostics (follow-up message) ──────────────
|
|
//
|
|
// Separate exported renderer for the follow-up message that arrives
|
|
// ~10-30s after the main /phonestatus output. Input is the array
|
|
// returned by services/dectCollectorService.collectAll(): per-base
|
|
// { ok, data (parsed status), verdict, error } records.
|
|
//
|
|
// Chat surface stays compact — most operators only need to see the
|
|
// exceptional stuff (warnings, recent power-loss reboots). Firmware
|
|
// / emergency numbers / detailed reboot log stay behind the future
|
|
// /dectstatus command where the full CLI-style dump makes more sense.
|
|
|
|
/**
|
|
* @param {Array} results collectAll() output
|
|
* @param {object} opts
|
|
* @param {string} opts.storeNum
|
|
* @param {boolean} [opts.footer=true]
|
|
* @returns {string} markdown, whitespace-trimmed. Empty string when
|
|
* the input list is empty (caller shouldn't send a message
|
|
* in that case).
|
|
*/
|
|
export function renderDectDiagnosticsMarkdown(results, opts = {}) {
|
|
const { storeNum, footer = true } = opts;
|
|
const list = Array.isArray(results) ? results : [];
|
|
if (list.length === 0) return '';
|
|
|
|
let out = `**DECT Base Station Diagnostics — Store ${storeNum}**\n\n`;
|
|
|
|
for (const r of list) {
|
|
out += renderOneBase(r);
|
|
out += '\n';
|
|
}
|
|
|
|
if (footer) {
|
|
out += `\n*Base diagnostics pulled at ${new Date().toLocaleTimeString()} via the DECT relay. Use \`/dectstatus ${storeNum}\` for full details and reboot/factory-reset controls.*`;
|
|
}
|
|
return out.trim();
|
|
}
|
|
|
|
function renderOneBase(r) {
|
|
const label = r.base?.name || `Basestation ${r.base?.mac || '?'}`;
|
|
const ip = r.base?.ip || '?';
|
|
if (!r.ok) {
|
|
return `⚠️ **${label}** (${ip}) — collect failed: ${r.error?.message || 'unknown error'}` +
|
|
(r.error?.hint ? `\n _${r.error.hint}_\n` : '\n');
|
|
}
|
|
|
|
const data = r.data || {};
|
|
const verdict = r.verdict || {};
|
|
const uptimeText = data.time?.operatingTime || '?';
|
|
const fw = data.firmware?.version || '?';
|
|
const conflict = data.conflictInfo && data.conflictInfo !== 'No Conflict'
|
|
? ` • RF conflict: ${data.conflictInfo}` : '';
|
|
const role = data.multiCell?.role ? ` • role: ${data.multiCell.role}` : '';
|
|
|
|
// Header line uses a checkmark or warning depending on verdict.
|
|
const icon = verdict.healthy ? '✅' : '⚠️';
|
|
let out = `${icon} **${label}** (${ip}) — uptime ${uptimeText} • fw ${fw}${role}${conflict}\n`;
|
|
|
|
// Most-recent Power Loss reboot (if any in the last-6 log) is the
|
|
// highest-signal thing we can surface here. Anything else falls
|
|
// under "warnings" below.
|
|
const powerLoss = (data.rebootLog || []).find((entry) => entry.reasonCode === 80);
|
|
if (powerLoss) {
|
|
out += ` ⚡ Recent power loss: ${powerLoss.at} (reboot #${powerLoss.sequence})\n`;
|
|
}
|
|
|
|
// Warnings from summarizeBaseHealth() are already user-facing
|
|
// strings; render as a bulleted list under the header.
|
|
if (Array.isArray(verdict.warnings) && verdict.warnings.length > 0) {
|
|
for (const w of verdict.warnings) {
|
|
// Skip the power-loss warning if we already surfaced the
|
|
// structured line above — avoids duplication.
|
|
if (powerLoss && /power.?loss/i.test(w)) continue;
|
|
out += ` ⚠️ ${w}\n`;
|
|
}
|
|
}
|
|
|
|
// RTP: only show if there's an active session — usually the
|
|
// diagnostic reader cares whether a call is up right now, not
|
|
// that this base has served 2 total calls since boot.
|
|
if ((data.rtp?.current || 0) > 0) {
|
|
out += ` 📞 ${data.rtp.current} active RTP session(s)\n`;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|