Split voice commands into status vs diag and redesign MPP phone output.

Replace /phonestatus follow-ups with /voicestatus, /wanstatus, /phonediag, and /dectdiag; extend /voicediag with relay probes and section-based MPP diagnostics.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-29 13:55:41 -04:00
parent f7953b8eb5
commit 7dc326c404
37 changed files with 1097 additions and 860 deletions

View file

@ -1,27 +1,9 @@
// src/commands/dectStatus.js
// src/commands/dectDiag.js
//
// /dectstatus <store> — full DECT basestation diagnostics via the
// on-prem relay agent. Complements the compact
// follow-up that /phonestatus already posts:
// this is the full status.xml dump (device,
// firmware, reboot log, network, RTP) plus chat-only action cards
// for reboot / force-reboot / factory-reset.
//
// Chat surface
// 1. Markdown full dump for every discovered 10.x base.
// 2. One adaptive card per reachable base with Reboot / Force Reboot /
// Factory Reset buttons. Each button opens a confirm card; confirm
// runs execAction() through the relay and audits the outcome.
//
// HTTP surface
// Same dump via ?storeNum=<n>. No action cards (mutating UI is chat-
// only). Non-mutating for registry purposes — card submits never hit
// the HTTP path.
//
// Relay dependency
// Requires DECT_RELAY_AGENT_TOKEN + a live dect-relay-agent. When the
// relay is offline we still run discovery and report the offline
// state explicitly rather than failing the whole command.
// /dectdiag <store> — full DECT basestation diagnostics via the
// on-prem relay agent: status.xml dump (device,
// firmware, reboot log, network, RTP) plus chat-only
// action cards for reboot / force-reboot / factory-reset.
import { randomUUID } from 'node:crypto';
import { collectPhoneStatus } from '../services/phoneService.js';
@ -71,7 +53,7 @@ export const DECT_STATUS_CARD_ACTIONS = new Set([
CANCEL_ACTION,
]);
export async function handleDectStatus(bot, trigger) {
export async function handleDectDiag(bot, trigger) {
const query = trigger.query || {};
const args = trigger.args || [];
const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim();
@ -80,33 +62,31 @@ export async function handleDectStatus(bot, trigger) {
await bot.say(
'markdown',
'Please provide a 24 digit store number.\n' +
'Example: `/dectstatus 782`\n' +
'Example: `/dectdiag 782`\n' +
'HTTP: `?storeNum=782`',
);
return;
}
// Optional single-base filter: second arg as IP or MAC.
const filterRaw = (args[1] || query.base || query.ip || query.mac || '').trim();
logger('dect:status', `Collecting DECT status for store ${storeNum}` +
logger('dect:diag', `Collecting DECT diagnostics for store ${storeNum}` +
(filterRaw ? ` (filter=${filterRaw})` : ''));
// Relay status snapshot for the header — cheap, no I/O.
let relayStatus = null;
try {
if (process.env.DECT_RELAY_AGENT_TOKEN) {
relayStatus = getDectRelayHub().status();
}
} catch (err) {
logger('dect:status', `Relay status unavailable: ${err.message}`, 'warn');
logger('dect:diag', `Relay status unavailable: ${err.message}`, 'warn');
}
if (!process.env.DECT_RELAY_AGENT_TOKEN) {
await bot.say(
'markdown',
'⚠️ DECT relay is not configured on this bot (`DECT_RELAY_AGENT_TOKEN` unset). ' +
'Set the token and run dect-relay-agent in the data center to enable `/dectstatus`.',
'Set the token and run dect-relay-agent in the data center to enable `/dectdiag`.',
);
return;
}
@ -115,7 +95,7 @@ export async function handleDectStatus(bot, trigger) {
try {
phoneData = await collectPhoneStatus(storeNum);
} catch (err) {
logger('dect:status', `collectPhoneStatus failed for store ${storeNum}: ${err.message}`, 'error');
logger('dect:diag', `collectPhoneStatus failed for store ${storeNum}: ${err.message}`, 'error');
await bot.say('markdown', `❌ Failed to look up store ${storeNum}: ${err.message}`);
return;
}
@ -139,7 +119,7 @@ export async function handleDectStatus(bot, trigger) {
await bot.say(
'markdown',
targets.length
? `⏳ Collecting status from **${targets.length}** DECT base(s) at store ${storeNum}`
? `⏳ Collecting diagnostics from **${targets.length}** DECT base(s) at store ${storeNum}`
: `Looking up DECT bases for store ${storeNum}`,
);
@ -153,7 +133,6 @@ export async function handleDectStatus(bot, trigger) {
});
await bot.say('markdown', md || 'No data available.');
// Action cards are chat-only (need adaptive-card UX + room).
if (trigger.person) {
const okBases = results.filter((r) => r.ok && r.base?.ip);
for (const r of okBases) {
@ -168,7 +147,7 @@ export async function handleDectStatus(bot, trigger) {
});
} catch (err) {
logger(
'dect:status',
'dect:diag',
`Failed to post action card for ${r.base?.ip}: ${err.message}`,
'warn',
);
@ -192,7 +171,7 @@ export async function handleDectStatusAction(bot, trigger) {
const dectAction = inputs.dectAction;
const meta = DECT_STATUS_ACTIONS[dectAction];
if (!meta) {
logger('dect:status:action', `Unknown dectAction "${dectAction}" — ignoring`, 'warn');
logger('dect:diag:action', `Unknown dectAction "${dectAction}" — ignoring`, 'warn');
return;
}
const storeNum = String(inputs.storeNum || '').trim();
@ -200,7 +179,7 @@ export async function handleDectStatusAction(bot, trigger) {
const baseMac = String(inputs.baseMac || '').trim();
const baseName = String(inputs.baseName || baseIp || 'base').trim();
if (!storeNum || !baseIp) {
logger('dect:status:action', 'request missing storeNum/baseIp — ignoring', 'warn');
logger('dect:diag:action', 'request missing storeNum/baseIp — ignoring', 'warn');
return;
}
@ -240,17 +219,17 @@ export async function handleDectStatusAction(bot, trigger) {
if (actionType === CONFIRM_ACTION || actionType === CANCEL_ACTION) {
const { cardId } = inputs;
if (!cardId) {
logger('dect:status:action', `Missing cardId on ${actionType} — ignoring`);
logger('dect:diag:action', `Missing cardId on ${actionType} — ignoring`);
return;
}
if (!pendingDectActions.has(cardId)) {
logger('dect:status:action', `Card ${cardId} is expired or unknown`);
await bot.say('markdown', '⚠️ That action card has expired. Re-run `/dectstatus` and try again.');
logger('dect:diag:action', `Card ${cardId} is expired or unknown`);
await bot.say('markdown', '⚠️ That action card has expired. Re-run `/dectdiag` and try again.');
return;
}
const data = pendingDectActions.get(cardId);
pendingDectActions.delete(cardId); // one-shot
pendingDectActions.delete(cardId);
if (actionType === CANCEL_ACTION) {
await bot.say(
@ -265,7 +244,6 @@ export async function handleDectStatusAction(bot, trigger) {
return;
}
// CONFIRM
const meta = DECT_STATUS_ACTIONS[data.dectAction] || {
id: data.dectAction,
label: data.dectAction,
@ -290,7 +268,7 @@ export async function handleDectStatusAction(bot, trigger) {
'markdown',
`✅ **${meta.label}** issued on **${data.baseName}** (${data.baseIp})` +
(result.elapsedMs != null ? ` in ${result.elapsedMs}ms` : '') +
`.\n_Re-run \`/dectstatus ${data.storeNum}\` in a minute to confirm the base is back._`,
`.\n_Re-run \`/dectdiag ${data.storeNum}\` in a minute to confirm the base is back._`,
);
logger(
'dect:audit',
@ -312,12 +290,10 @@ export async function handleDectStatusAction(bot, trigger) {
return;
}
logger('dect:status:action', `Ignoring unhandled action type ${actionType}`, 'debug');
logger('dect:diag:action', `Ignoring unhandled action type ${actionType}`, 'debug');
void roomId;
}
// ─── Cards ──────────────────────────────────────────────────────────
function buildBaseActionCard({ storeNum, base }) {
const actions = Object.values(DECT_STATUS_ACTIONS).map((meta) => ({
type: 'Action.Submit',
@ -402,8 +378,6 @@ function buildConfirmCard({ cardId, storeNum, baseName, baseIp, meta }) {
};
}
// ─── Helpers ────────────────────────────────────────────────────────
function filterBases(bases, raw) {
const needle = String(raw).trim().toLowerCase();
const needleMac = needle.replace(/[^0-9a-f]/g, '');

View file

@ -18,9 +18,11 @@ const SHORT_HELP = {
// AV / phones
avstatus: 'AV / device status for a store (alias: /wostatus)',
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
dectstatus: 'Full DECT base dump via relay (handsets, RSSI, reboot cards)',
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
voicestatus: 'Quick DECT + IP phone status for a store',
wanstatus: 'Prisma SD-WAN health + voice traffic quality',
phonediag: 'MPP desk phone relay diagnostics (CP-7841)',
dectdiag: 'Full DECT base dump via relay (handsets, RSSI, reboot cards)',
voicediag: 'Deep voice diagnostic: features + WAN + relay probes with fix cards',
callreport: 'Daily call digest: correlated CDR calls + per-call Prisma WAN (store, email, or phone)',
calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)',
@ -53,41 +55,70 @@ const LONG_HELP = {
'Includes Meraki deep links per device. For interactive topology, open `/av-store-dashboard.html`.',
],
},
phonestatus: {
title: '/phonestatus',
voicestatus: {
title: '/voicestatus',
usage: [
'/phonestatus <store>',
'/phonestatus <store> detailed',
'/phonestatus <store> verbose',
'/phonestatus <store> detailed verbose',
'/voicestatus <store>',
'/voicestatus <store> detailed',
],
examples: [
'/phonestatus 782',
'/phonestatus 782 detailed',
'/phonestatus 782 verbose',
'/phonestatus 782 debug',
'/voicestatus 782',
'/voicestatus 782 detailed',
],
notes: [
'Shows DECT basestations + IP phones with Meraki links.',
'Detailed mode adds firmware, serial, SIP details and errors (main message only).',
'When MPP desk phones are discovered on 10.x, a follow-up **MPP Desk Phone Diagnostics** message arrives via the relay (registration, switch port, provisioning history). Pass `verbose` or `debug` for extension/debug counters.',
'When the store is Prisma SD-WAN managed (site name `CG<store>` padded to 5 digits), a follow-up **WAN Diagnostics** message arrives with per-path latency/jitter/loss/MOS, site healthscore, and any alarms — averaged over the last 7 days by default (widened from 24h so sporadic Webex Calling stores get enough call samples; override via `WAN_STANDARD_WINDOW_MINUTES` or pass `--window 24h` on /voicediag).',
'If `PRISMA_APP_ID_VOICE` is configured (e.g. pointing at `Webex_Calling_RTP` for Webex Calling shops), the follow-up also includes a **Voice Traffic Quality** section with real DPI-measured MOS / packet loss / jitter for that app — surfaces transient degradation the 7d link-probe averages smooth away.',
'For the full DECT base dump + reboot/factory-reset controls, use `/dectstatus <store>`.',
'Shows DECT basestations + IP phones with Meraki links — quick scan only, no follow-up messages.',
'Detailed mode adds firmware, serial, SIP details and errors.',
'For MPP desk phone relay diagnostics, use `/phonediag <store>`.',
'For Prisma SD-WAN metrics, use `/wanstatus <store>`.',
'For full DECT base dump + reboot/factory-reset controls, use `/dectdiag <store>`.',
'For an in-depth voice diagnostic with per-user Webex Calling checks + fix cards, use `/voicediag <store>`.',
'Web dashboard: `/phone-store-dashboard.html`.',
],
},
dectstatus: {
title: '/dectstatus',
wanstatus: {
title: '/wanstatus',
usage: [
'/dectstatus <store>',
'/dectstatus <store> <ip|mac>',
'/wanstatus <store>',
'/wanstatus <store> --window 24h',
],
examples: [
'/dectstatus 782',
'/dectstatus 782 10.12.34.56',
'/dectstatus 782 6c:ab:05:12:34:56',
'/wanstatus 782',
'/wanstatus 782 --window 7d',
],
notes: [
'Prisma SD-WAN diagnostics for stores managed under site name `CG<store>` (padded to 5 digits).',
'Shows healthscore, per-path latency/jitter/loss/MOS, active alarms — default window 7 days.',
'When `PRISMA_APP_ID_VOICE` is configured, includes **Voice Traffic Quality** (DPI-measured MOS / loss / jitter).',
'Override window via `--window 24h`, `7d`, etc. HTTP: `?storeNum=<n>&window=24h`.',
],
},
phonediag: {
title: '/phonediag',
usage: [
'/phonediag <store>',
'/phonediag <store> verbose',
],
examples: [
'/phonediag 782',
'/phonediag 782 verbose',
'/phonediag 782 debug',
],
notes: [
'MPP desk phone relay diagnostics for CP-7841 phones on 10.x: registration, switch LLDP, provisioning, issues.',
'Pass `verbose` or `debug` for SIP counters, extra extensions, and probe path table.',
'Requires `DECT_RELAY_AGENT_TOKEN` and a live dect-relay-agent.',
],
},
dectdiag: {
title: '/dectdiag',
usage: [
'/dectdiag <store>',
'/dectdiag <store> <ip|mac>',
],
examples: [
'/dectdiag 782',
'/dectdiag 782 10.12.34.56',
'/dectdiag 782 6c:ab:05:12:34:56',
],
notes: [
'Aliases: `/dect`.',
@ -95,9 +126,8 @@ const LONG_HELP = {
'Also shows **Webex handset registrations** per base (extension, last registration) and **RF signal (RSSI)** from the base when handsets are registered.',
'Optional second arg filters to one base by IP, MAC, or name substring.',
'Chat-only action cards per reachable base: **Reboot**, **Force Reboot**, **Factory Reset**. Each requires a confirm click; outcomes are audited under `dect:audit`.',
'Requires `DECT_RELAY_AGENT_TOKEN` on the bot and a live `dect-relay-agent` in the data center. When the relay is offline the command still reports discovery results and the offline state.',
'Requires `DECT_RELAY_AGENT_TOKEN` on the bot and a live `dect-relay-agent` in the data center.',
'HTTP equivalent: `?storeNum=<n>[&base=<ip|mac>]` — markdown dump only (no action cards).',
'The compact exception-only DECT follow-up after `/phonestatus` is separate; this command is the full dump that follow-up footer points at.',
],
},
voicediag: {
@ -119,7 +149,7 @@ const LONG_HELP = {
'/voicediag list-checks',
],
notes: [
'Runs a full battery of per-user Webex Calling checks (DND, call forwarding, voicemail, call intercept, call waiting, outgoing permission, etc.) plus eleven SD-WAN checks (site, healthscore, link state, latency, jitter, loss, MOS, per-app voice MOS/loss/jitter, alarms) against the store\'s Prisma tenant.',
'Runs per-user Webex Calling checks, SD-WAN checks, and relay probes (MPP desk phones + DECT bases when `DECT_RELAY_AGENT_TOKEN` is set).',
'The per-app checks measure REAL voice-traffic quality via Prisma DPI (worst 5-minute window over the configured look-back, default 7d), which catches transient degradation the link-probe averages smooth away. Feature-gated on `PRISMA_APP_ID_VOICE` env (set to a Prisma app id like `Webex_Calling_RTP` or `rtp-base`) — checks return skipped with an explanation when not configured.',
'Default view hides OK checks and highlights errors/warnings/skipped. Pass `detail` (or `detailed`) to also see OK checks with expanded per-link tables + thresholds + roll-ups.',
'Fixable issues (e.g. DND on, forwarding to wrong number) post a per-issue confirmation card. A single "apply all" card lets you fix everything at once after reviewing.',
@ -260,7 +290,7 @@ const LONG_HELP = {
examples: ['/jirapoll', '/jirapoll prime'],
notes: [
'Triggers the same Jira poller that normally runs at the top of every hour. Enriches any unlabeled matching tickets with a phone/av snapshot comment and labels them `bot-enriched`. Tickets the AI classifier decides are out-of-scope get labeled `bot-skipped` so they aren\'t re-classified every hour.',
'**Communication Services** tickets mentioning call quality (garbled, static, can\'t connect, …) get **phonestatus + callreport (today)**. Spam/robocall tickets get **callreport** only. See `services/jiraPoller/README.md` for adding more rules.',
'**Communication Services** tickets mentioning call quality (garbled, static, can\'t connect, …) get **voicestatus + callreport (today)**. Spam/robocall tickets get **callreport** only. See `services/jiraPoller/README.md` for adding more rules.',
'Idempotent — labels + JQL prevent double-processing, so running multiple times in a row is safe.',
'`/jirapoll prime` labels every matching ticket without enriching or notifying. Use once after adopting the poller to skip enriching the existing backlog. Same as `JIRA_POLLER_PRIME_ON_START=true` at startup.',
'A summary of enriched tickets goes to the configured `JIRA_POLLER_ROOM_ID`. The invoking chat also gets a compact result line.',
@ -322,7 +352,7 @@ const LONG_HELP = {
const GROUPS = [
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'callreport', 'calltest'] },
{ title: 'AV & phones', keys: ['avstatus', 'voicestatus', 'wanstatus', 'phonediag', 'dectdiag', 'voicediag', 'callreport', 'calltest'] },
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },

View file

@ -2,7 +2,7 @@
//
// Adaptive-card inline remediation for Meraki switch multicast policy
// on a store's network. Not a standalone chat command — the card is
// emitted by `/phonestatus` (see commands/phoneStatus.js) whenever the
// emitted by `/voicestatus` (see commands/voiceStatus.js) whenever the
// collector reports `data.multicast.needsFix`. Confirm and Cancel
// flow through the framework's `attachmentAction` event dispatched
// from index.js, which follows the same pending-card / one-shot /
@ -34,7 +34,7 @@ export const DECT_SAFE_MULTICAST_PAYLOAD = Object.freeze({
});
/**
* Build the adaptive-card object shown in-chat under a /phonestatus
* Build the adaptive-card object shown in-chat under a /voicestatus
* reply when multicast policy deviates from DECT-safe defaults. The
* card renders whatever the summarizer flagged as deviating so the
* clicker knows exactly what will change.
@ -150,7 +150,7 @@ export async function applyDectSafeMulticast(bot, data, _roomId, requester) {
'markdown',
`✅ Multicast pinned to DECT-safe defaults on **${networkName || `store ${storeNum}`}**: ` +
`IGMP snoop=OFF, flood-unknown=ON, all switch overrides cleared. ` +
`Re-run \`/phonestatus ${storeNum}\` to verify.`,
`Re-run \`/voicestatus ${storeNum}\` to verify.`,
);
logger(
'igmp:audit',

84
commands/phoneDiag.js Normal file
View file

@ -0,0 +1,84 @@
// src/commands/phoneDiag.js
//
// /phonediag <store> [verbose|debug]
//
// MPP desk phone relay diagnostics (CP-7841 etc.): registration,
// switch LLDP, provisioning history, and formatted issues.
import { collectPhoneStatus } from '../services/phoneService.js';
import { discoverDeskPhones } from '../services/phoneDiscovery.js';
import { probeAll } from '../services/phoneCollectorService.js';
import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js';
import { logger } from '../utils/logger.js';
export async function handlePhoneDiag(bot, trigger) {
const query = trigger.query || {};
const args = trigger.args || [];
const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim();
const isVerbose = argIncludes(args, 'verbose') || argIncludes(args, 'debug')
|| query.verbose === 'true' || query.verbose === true
|| query.debug === 'true' || query.debug === true;
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
await bot.say(
'markdown',
'Please provide a 24 digit store number.\n' +
'Examples:\n' +
'- `/phonediag 782`\n' +
'- `/phonediag 782 verbose`',
);
return;
}
if (!process.env.DECT_RELAY_AGENT_TOKEN) {
await bot.say(
'markdown',
'⚠️ Phone relay is not configured on this bot (`DECT_RELAY_AGENT_TOKEN` unset). ' +
'Set the token and run dect-relay-agent in the data center to enable `/phonediag`.',
);
return;
}
logger('phone:diag', `Collecting MPP phone diagnostics for store ${storeNum}`);
let phoneData;
try {
phoneData = await collectPhoneStatus(storeNum);
} catch (err) {
logger('phone:diag', `collectPhoneStatus failed for store ${storeNum}: ${err.message}`, 'error');
await bot.say('markdown', `❌ Failed to look up store ${storeNum}: ${err.message}`);
return;
}
const { phones, warnings } = discoverDeskPhones(phoneData || {});
if (warnings.length > 0) {
logger(
'phone:diag',
`MPP discovery warnings for store ${storeNum}: ${warnings.map((w) => w.reason).join('; ')}`,
'warn',
);
}
if (phones.length === 0) {
await bot.say(
'markdown',
`No MPP desk phones discovered on 10.x for store ${storeNum}.`,
);
return;
}
await bot.say(
'markdown',
`⏳ Probing **${phones.length}** MPP desk phone(s) at store ${storeNum} via relay…`,
);
const results = await probeAll(phones);
const md = renderMppPhoneDiagnosticsMarkdown(results, { storeNum, verbose: isVerbose });
await bot.say('markdown', md || 'No data available.');
}
function argIncludes(args, token) {
const needle = String(token).toLowerCase();
return (args || []).some((a) => String(a).toLowerCase() === needle);
}

View file

@ -1,289 +0,0 @@
// 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 { renderWanDiagnosticsMarkdown } from '../services/renderers/wanDiagnosticsRenderer.js';
import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.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 { discoverDeskPhones } from '../services/phoneDiscovery.js';
import { collectAll } from '../services/dectCollectorService.js';
import { probeAll } from '../services/phoneCollectorService.js';
import { siteNameForStore, findSdwanSiteForStore } from '../integrations/paloalto/sites.js';
import { collectSdwanForStore } from '../services/enrichment/sdwanEnrichment.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 = argIncludes(args, 'detailed')
|| (query.mode === 'detailed')
|| (query.detailed === 'true' || query.detailed === true);
const isVerbose = argIncludes(args, 'verbose') || argIncludes(args, 'debug')
|| query.verbose === 'true' || query.verbose === true
|| query.debug === 'true' || query.debug === 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: [] };
const { phones: mppPhones, warnings: mppDiscoveryWarnings } = dectFollowUpEnabled
? discoverDeskPhones(data)
: { phones: [], warnings: [] };
if (discoveryWarnings.length > 0) {
logger(
'phone:status',
`DECT discovery warnings for store ${storeNum}: ${discoveryWarnings.map((w) => w.reason).join('; ')}`,
'warn',
);
}
if (mppDiscoveryWarnings.length > 0) {
logger(
'phone:status',
`MPP discovery warnings for store ${storeNum}: ${mppDiscoveryWarnings.map((w) => w.reason).join('; ')}`,
'warn',
);
}
if (dectFollowUpEnabled) {
logger(
'phone:status',
`MPP discovery for store ${storeNum}: ` +
`${mppPhones.length > 0
? `${mppPhones.length} desk phone(s) selected — follow-up scheduled (${mppPhones.map((p) => p.ip).join(', ')})`
: 'no MPP desk phones selected — no follow-up'}`,
);
}
// WAN follow-up discovery. Cheap Prisma-side check: hits the
// 4-hour-cached sites list to confirm this store is Prisma-
// managed before we promise a follow-up. Anything that throws
// (missing env, auth failure, network error) is swallowed and
// treated as "no site" so a broken Prisma integration cannot
// break /phonestatus. Chat-only, same as DECT.
let wanFollowUpEnabled = false;
if (trigger.person) {
const expectedSite = safeSiteName(storeNum);
try {
const site = await findSdwanSiteForStore(storeNum);
wanFollowUpEnabled = !!site;
// Info-level breadcrumb so operators can see the discovery
// outcome without cranking LOG_LEVEL=debug. The success case
// is also logged inside findSdwanSiteForStore; this line
// provides the "why /phonestatus did / didn't schedule a
// WAN follow-up" answer at the phone:status scope.
logger(
'phone:status',
`WAN discovery for store ${storeNum} (expected ${expectedSite}): ` +
`${wanFollowUpEnabled ? 'MATCHED — follow-up scheduled' : 'no match — no follow-up'}`,
);
} catch (err) {
logger(
'phone:status',
`WAN discovery skipped for store ${storeNum} (expected ${expectedSite}): ${err.message}`,
'warn',
);
}
}
const reply = renderPhoneStatusMarkdown(data, {
storeNum,
detailed: isDetailed,
footer: true,
dectFollowUpBaseCount: reachableBases.length,
mppFollowUpPhoneCount: mppPhones.length,
wanFollowUpEnabled,
});
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');
});
}
if (dectFollowUpEnabled && mppPhones.length > 0) {
runMppPhoneFollowUp(bot, storeNum, mppPhones, { verbose: isVerbose }).catch((err) => {
logger('phone:status', `MPP phone follow-up failed for store ${storeNum}: ${err.message}`, 'error');
});
}
// WAN follow-up (mirrors the DECT pattern). Only kicked when
// discovery above already confirmed we have a Prisma site for
// this store. Fire-and-forget with a stable log scope.
if (wanFollowUpEnabled) {
runWanFollowUp(bot, storeNum).catch((err) => {
logger('phone:status', `WAN 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);
}
async function runMppPhoneFollowUp(bot, storeNum, phones, { verbose = false } = {}) {
const results = await probeAll(phones);
const md = renderMppPhoneDiagnosticsMarkdown(results, { storeNum, verbose });
if (!md) return;
await bot.say('markdown', md);
}
/**
* Prisma SD-WAN follow-up. Runs the enrichment composer (which
* hydrates site + elements + healthscore + per-path LQM + alarms in
* parallel), hands the result to the pure WAN renderer, and posts.
*
* The composer never throws every metric failure is preserved in
* `data.errors[]` and rendered as an inline "partial fetch" warning
* so the operator can see WHAT failed rather than getting silence.
* A missing site (unexpected here since we pre-discovered) short-
* circuits with an empty markdown string, and we no-op.
*/
async function runWanFollowUp(bot, storeNum) {
const data = await collectSdwanForStore(storeNum);
const md = renderWanDiagnosticsMarkdown(data, { storeNum });
if (!md) return;
await bot.say('markdown', md);
}
/**
* Small helper for a log line that runs before we've committed to
* a site lookup used inside the catch branch of WAN discovery
* where we want to show the expected site name even if the lookup
* failed. Isolated in a function so the try/catch is single-line
* and the intent is obvious.
*/
function safeSiteName(storeNum) {
try {
return siteNameForStore(storeNum);
} catch {
return '<invalid store number>';
}
}
function argIncludes(args, token) {
const needle = String(token).toLowerCase();
return (args || []).some((a) => String(a).toLowerCase() === needle);
}

View file

@ -13,8 +13,10 @@
import { handleHelp } from './help.js';
import { handleAvStatus } from './avStatus.js';
import { handlePhoneStatus } from './phoneStatus.js';
import { handleDectStatus } from './dectStatus.js';
import { handleVoiceStatus } from './voiceStatus.js';
import { handleWanStatus } from './wanStatus.js';
import { handlePhoneDiag } from './phoneDiag.js';
import { handleDectDiag } from './dectDiag.js';
import { handleProvisionDect } from './provisionDect.js';
import { handleWoHistory } from './woHistory.js';
import { handleWoSummary } from './woSummary.js';
@ -46,11 +48,13 @@ export const commands = [
{ name: 'help', handler: handleHelp, mutating: false, http: false },
{ name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false },
{ name: 'phonestatus', handler: handlePhoneStatus, mutating: false },
// /dectstatus — full DECT base dump via the on-prem relay + chat-only
{ name: 'voicestatus', handler: handleVoiceStatus, mutating: false },
{ name: 'wanstatus', handler: handleWanStatus, mutating: false },
{ name: 'phonediag', handler: handlePhoneDiag, mutating: false },
// /dectdiag — full DECT base dump via the on-prem relay + chat-only
// reboot/factory-reset cards. Read path is non-mutating; card submits
// only fire over Webex (see commands/dectStatus.js).
{ name: 'dectstatus', aliases: ['dect'], handler: handleDectStatus, mutating: false },
// only fire over Webex (see commands/dectDiag.js).
{ name: 'dectdiag', aliases: ['dect'], handler: handleDectDiag, mutating: false },
// /voicediag reads per-user Webex Calling features via
// /v1/people/{id}/features/* and offers per-issue adaptive-card
// remediation. Reads are non-mutating but the confirm buttons on

View file

@ -33,6 +33,9 @@ import {
buildRemediationRegistry,
CHECKS,
} from '../services/voiceDiag/voiceDiagService.js';
import { pickWindowArg, parseWindowMinutes } from '../utils/windowArgs.js';
export { parseWindowMinutes };
// Built once at module load. `buildRemediationRegistry` throws on
// duplicate action ids, so any collision surfaces at import time —
@ -58,7 +61,7 @@ export async function handleVoiceDiag(bot, trigger) {
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
// Accept every reasonable variant so the footer hint ("pass
// `detailed`") and the /phonestatus muscle memory ("`detailed`" as
// `detailed`") and the /voicestatus muscle memory ("`detailed`" as
// the second positional) both work, alongside the flag forms
// (`--detail`, `--detailed`) and the `?detailed=true` query param.
const detailed = argIncludes(args, 'detail')
@ -145,7 +148,7 @@ export async function handleVoiceDiag(bot, trigger) {
// Adaptive-card remediation is chat-only. HTTP callers get the
// markdown snapshot but no interactive cards (same rule the
// /phonestatus + IGMP-fix inline card follows). `trigger.person`
// /voicestatus + IGMP-fix inline card follows). `trigger.person`
// is populated for chat, absent for the HTTP fake trigger.
if (!trigger.person) return;
@ -348,10 +351,6 @@ function pickOnlyArg(args) {
return pickFlagValue(args, '--only');
}
function pickWindowArg(args) {
return pickFlagValue(args, '--window');
}
// Generic `--flag=value` / `--flag value` extractor.
function pickFlagValue(args, flag) {
const prefix = `${flag.toLowerCase()}=`;
@ -363,27 +362,6 @@ function pickFlagValue(args, flag) {
return null;
}
/**
* Parse a window shorthand (`15m`, `1h`, `24h`, `1d`, `1440`) into
* minutes. Returns null on unrecognised input so the caller can
* decide whether to surface a friendly error or fall back to the
* env default.
*/
export function parseWindowMinutes(raw) {
if (raw === null || raw === undefined || raw === '') return undefined;
const s = String(raw).trim().toLowerCase();
// Bare integer → treat as minutes.
if (/^\d+$/.test(s)) return Math.max(1, parseInt(s, 10));
const m = s.match(/^(\d+)\s*(m|min|mins|h|hr|hrs|hour|hours|d|day|days)$/);
if (!m) return null;
const n = parseInt(m[1], 10);
const unit = m[2];
if (['m', 'min', 'mins'].includes(unit)) return n;
if (['h', 'hr', 'hrs', 'hour', 'hours'].includes(unit)) return n * 60;
if (['d', 'day', 'days'].includes(unit)) return n * 60 * 24;
return null;
}
function renderListChecks() {
let md = '**/voicediag registered checks**\n\n';
for (const c of CHECKS) {

103
commands/voiceStatus.js Normal file
View file

@ -0,0 +1,103 @@
// src/commands/voiceStatus.js
//
// Chat + HTTP entry point for /voicestatus. 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 } 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';
export async function handleVoiceStatus(bot, trigger) {
logger('voice:status', 'Handler entered', 'debug');
const query = trigger.query || {};
const args = trigger.args || [];
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
const isDetailed = argIncludes(args, '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: `/voicestatus 782` (or `detailed` / `?detailed=true` for more; includes Meraki links) or `https://.../voicestatus?storeNum=782`';
await bot.say('markdown', errorMsg);
return;
}
logger('voice:status', `Collecting voice status for store ${storeNum}`, 'debug');
try {
const data = await collectPhoneStatus(storeNum);
if (!data) throw new Error('collectPhoneStatus returned undefined');
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;
}
const reply = renderPhoneStatusMarkdown(data, {
storeNum,
detailed: isDetailed,
footer: true,
});
await bot.say('markdown', reply || 'No data available.');
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,
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('voice:status', `Error collecting voice status for store ${storeNum}: ${err.message}`, 'error');
await bot.say('markdown', `Error collecting voice status: ${err.message}`);
}
}
function argIncludes(args, token) {
const needle = String(token).toLowerCase();
return (args || []).some((a) => String(a).toLowerCase() === needle);
}

77
commands/wanStatus.js Normal file
View file

@ -0,0 +1,77 @@
// src/commands/wanStatus.js
//
// /wanstatus <store> [--window 24h]
//
// Prisma SD-WAN diagnostics for a store: healthscore, per-path LQM,
// voice traffic quality (when configured), and active alarms.
import { collectSdwanForStore } from '../services/enrichment/sdwanEnrichment.js';
import { renderWanDiagnosticsMarkdown } from '../services/renderers/wanDiagnosticsRenderer.js';
import { findSdwanSiteForStore, siteNameForStore } from '../integrations/paloalto/sites.js';
import { pickWindowArg, parseWindowMinutes } from '../utils/windowArgs.js';
import { logger } from '../utils/logger.js';
export async function handleWanStatus(bot, trigger) {
const query = trigger.query || {};
const args = trigger.args || [];
const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim();
const windowRaw = pickWindowArg(args) ?? query.window ?? null;
const windowMinutes = parseWindowMinutes(windowRaw);
if (windowRaw && windowMinutes == null) {
await bot.say(
'markdown',
`Unrecognised window \`${windowRaw}\`. Use \`15m\`, \`1h\`, \`6h\`, \`24h\`, \`1d\`, or \`7d\`.`,
);
return;
}
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
await bot.say(
'markdown',
'Please provide a 24 digit store number.\n' +
'Examples:\n' +
'- `/wanstatus 782`\n' +
'- `/wanstatus 782 --window 24h`\n' +
'HTTP: `?storeNum=782&window=7d`',
);
return;
}
logger('wan:status', `Collecting WAN status for store ${storeNum}`);
let site;
try {
site = await findSdwanSiteForStore(storeNum);
} catch (err) {
logger('wan:status', `Site lookup failed for store ${storeNum}: ${err.message}`, 'warn');
await bot.say(
'markdown',
`❌ WAN lookup failed for store ${storeNum}: ${err.message}`,
);
return;
}
if (!site) {
const expected = safeSiteName(storeNum);
await bot.say(
'markdown',
`Store ${storeNum} is not Prisma SD-WAN managed (expected site \`${expected}\`).`,
);
return;
}
await bot.say('markdown', `⏳ Collecting WAN metrics for store ${storeNum}`);
const data = await collectSdwanForStore(storeNum, { windowMinutes });
const md = renderWanDiagnosticsMarkdown(data, { storeNum });
await bot.say('markdown', md || 'No WAN data available.');
}
function safeSiteName(storeNum) {
try {
return siteNameForStore(storeNum);
} catch {
return '<invalid store number>';
}
}

View file

@ -23,7 +23,7 @@ import { handleDectProvisionAction } from './commands/provisionDect.js';
import {
handleDectStatusAction,
DECT_STATUS_CARD_ACTIONS,
} from './commands/dectStatus.js';
} from './commands/dectDiag.js';
import {
applyOffboardConfirmation,
cancelOffboardCard,
@ -840,7 +840,7 @@ if (process.env.DECT_RELAY_AGENT_TOKEN) {
} else {
logger(
'startup',
'DECT relay disabled — set DECT_RELAY_AGENT_TOKEN in .env (and share the same value with dect-relay-agent) to enable /phonestatus DECT follow-up + /dectstatus',
'DECT relay disabled — set DECT_RELAY_AGENT_TOKEN in .env (and share the same value with dect-relay-agent) to enable /phonediag, /dectdiag, and /voicediag relay probes',
'warn',
);
}

View file

@ -78,13 +78,37 @@ export function downloadStatusWarnings(download) {
const warnings = [];
if (!download) return warnings;
if (download.micCert?.failed) {
warnings.push(`MIC cert: ${download.micCert.provisioningStatus}`);
warnings.push('mic_cert_failed');
}
if (download.latestProvisioning?.failed) {
warnings.push(`provisioning: ${download.latestProvisioning.result}`);
warnings.push('provisioning_failed');
}
if (download.latestFirmwareUpgrade?.failed) {
warnings.push(`firmware upgrade: ${download.latestFirmwareUpgrade.result}`);
warnings.push('firmware_upgrade_failed');
}
return warnings;
}
/**
* Human-readable MIC cert issue for chat rendering (no raw URLs).
* @param {{ provisioningStatus?: string|null, info?: string|null, failed?: boolean }|null} micCert
* @returns {string|null}
*/
export function formatMicCertForDisplay(micCert) {
if (!micCert?.failed && !micCert?.provisioningStatus) return null;
const entry = parseHistoryEntry(micCert.provisioningStatus || micCert.info);
const at = entry?.timestamp || null;
const reason = shortenMicReason(entry?.result || micCert.provisioningStatus || 'download failed');
const atSuffix = at ? ` (${at})` : '';
return `MIC cert download failed — ${reason}${atSuffix}`;
}
function shortenMicReason(raw) {
if (!raw || typeof raw !== 'string') return 'unknown error';
const reasonMatch = raw.match(/Reason:\s*(.+?)\.?$/i);
if (reasonMatch) return reasonMatch[1].trim();
const failedMatch = raw.match(/MIC Cert Download Failed\.?\s*(.*)$/i);
if (failedMatch && failedMatch[1].trim()) return failedMatch[1].replace(/^Reason:\s*/i, '').trim();
if (/fail/i.test(raw)) return 'download failed';
return raw.length > 80 ? `${raw.slice(0, 77)}` : raw;
}

View file

@ -1,6 +1,6 @@
// services/dectStatus/buildHandsetContext.js
//
// Group Webex DECT handset inventory for /dectstatus rendering.
// Group Webex DECT handset inventory for /dectdiag rendering.
/**
* @param {object|null} phoneData collectPhoneStatus() output

View file

@ -1,6 +1,6 @@
// src/services/enrichment/sdwanEnrichment.js
//
// One-stop composer for Prisma SD-WAN data used by /phonestatus's
// One-stop composer for Prisma SD-WAN data used by /wanstatus and
// WAN follow-up and /voicediag's WAN check bucket. Same "fire the
// fetches in parallel + normalise into a stable shape + preserve
// per-metric failures in errors[]" pattern used by the Meraki
@ -80,7 +80,7 @@ import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js';
import { coerceAlarmTsMs } from './alarmSemantics.js';
/**
* Compose everything /voicediag + /phonestatus care about for a
* Compose everything /voicediag + /wanstatus care about for a
* store's Prisma SD-WAN posture. Never throws always returns the
* shape documented at the top of this file.
*

View file

@ -22,9 +22,9 @@ For tickets with component **Communication Services** and a resolvable store:
| Symptom keywords | Checks run |
|------------------|------------|
| Call quality / connectivity (garbled, static, can't connect, …) | `phonestatus` + `callreport` |
| Call quality / connectivity (garbled, static, can't connect, …) | `voicestatus` + `callreport` |
| Spam / robocall / nuisance | `callreport` only |
| Neither | Default: `phone`phonestatus, `av` → avstatus |
| Neither | Default: `phone`voicestatus, `av` → avstatus |
`callreport` uses the **today** business window (9am local → now minus 5 min).

View file

@ -4,7 +4,7 @@
export const COMM_SERVICES_COMPONENT = 'Communication Services';
export const CHECK_IDS = {
phonestatus: 'phonestatus',
voicestatus: 'voicestatus',
callreport: 'callreport',
avstatus: 'avstatus',
};
@ -109,7 +109,7 @@ export function resolveEnrichmentPlan(ticket, classification) {
}
const checks = [];
if (callQuality) {
checks.push(CHECK_IDS.phonestatus, CHECK_IDS.callreport);
checks.push(CHECK_IDS.voicestatus, CHECK_IDS.callreport);
} else if (spam) {
checks.push(CHECK_IDS.callreport);
}
@ -128,7 +128,7 @@ export function resolveEnrichmentPlan(ticket, classification) {
if (classification.kind === 'phone') {
return {
checks: [CHECK_IDS.phonestatus],
checks: [CHECK_IDS.voicestatus],
matchedRules: ['default-phone'],
storeNum,
skip: false,

View file

@ -14,10 +14,10 @@ import { formatEnrichmentBody } from './formatBody.js';
const LOG_SCOPE = 'jira:poller:enrich';
const CHECK_RUNNERS = {
[CHECK_IDS.phonestatus]: async (storeNum) => {
[CHECK_IDS.voicestatus]: async (storeNum) => {
const data = await collectPhoneStatus(storeNum);
return {
title: 'Phone status',
title: 'Voice status',
markdown: renderPhoneStatusMarkdown(data, {
storeNum,
detailed: true,

View file

@ -27,7 +27,7 @@
// line — so future non-store enrichment tooling can pick it up later.
//
// Comment format — Jira Cloud v3 requires ADF. We emit an italic
// header paragraph followed by the same markdown the /phonestatus or
// header paragraph followed by the same markdown the /voicestatus or
// /avstatus chat command would produce, converted to ADF paragraphs
// via utils/markdownToAdf. This preserves clickable Meraki links,
// bold device names, and paragraph structure that the previous

View file

@ -3,7 +3,7 @@
// Thin wrapper for phone data rich/build shape for the phone store dashboard.
// Delegates to collectPhoneStatus (which already does Webex phones/DECT + Meraki attach + relevant ports).
// Adds optional Meraki topology for viz mirroring the AV dashboard.
// Keeps backward compat for the /phonestatus command (which uses collect directly).
// Keeps backward compat for the /voicestatus command (which uses collect directly).
import { collectPhoneStatus } from './phoneService.js';
import { getMerakiTopology } from '../integrations/meraki/devices.js';

View file

@ -1,7 +1,6 @@
// src/services/renderers/dectStatusRenderer.js
//
// Full CLI-style DECT base dump for `/dectstatus`. Complements the
// compact follow-up from renderDectDiagnosticsMarkdown (phonestatus).
// Full CLI-style DECT base dump for `/dectdiag`.
import { formatDisplayTime, simpleTimeAgo } from '../../utils/time.js';
import { handsetsForBase } from '../dectStatus/buildHandsetContext.js';

View file

@ -1,8 +1,18 @@
// services/renderers/mppPhoneDiagnosticsRenderer.js
//
// MPP desk phone diagnostics follow-up for /phonestatus (via relay).
// MPP desk phone diagnostics for /phonediag (via relay).
import { formatDisplayTime } from '../../utils/time.js';
import {
shortenFirmware,
formatHeaderStatusLine,
headerIconForLine,
isLineRegistered,
formatRebootEntry,
formatProvisioningResync,
formatFirmwareUpgrade,
collectIssues,
} from './mppPhoneFormatters.js';
/**
* @param {Array} results probeAll() output
@ -40,165 +50,148 @@ function renderOnePhone(r, { verbose }) {
const ip = r.phone?.ip || r.parsed?.network?.ipv4 || '?';
if (!r.ok) {
return `⚠️ **${label}** (${ip}) — probe failed: ${r.error?.message || 'unknown error'}` +
(r.error?.hint ? `\n _${r.error.hint}_` : '');
return `⚠️ **${label}** · \`${ip}\` — probe failed: ${r.error?.message || 'unknown error'}` +
(r.error?.hint ? `\n_${r.error.hint}_` : '');
}
const mpp = r.mpp || {};
const parsed = mpp.parsed || r.parsed || {};
const verdict = mpp.verdict || r.verdict || {};
const device = parsed.device || {};
const network = parsed.network || {};
const phoneStatus = parsed.phoneStatus || {};
const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0];
const neighbor = mpp.networkNeighbor || {};
const download = mpp.download || {};
const system = mpp.system || {};
const issues = collectIssues(mpp, parsed);
const icon = verdict.healthy ? '✅' : '⚠️';
const fwShort = shortenFirmware(device.firmware);
let out = `${icon} **${label}** (${ip})\n`;
out += ` ${device.product || '?'} · ${device.mac || '?'} · ${fwShort}\n`;
if (line1?.registrationState) {
out += ` Ext ${line1.index}: ${line1.registrationState}`;
if (line1.lastRegistrationIp) out += `${line1.lastRegistrationIp}`;
if (line1.lastRegistrationAt) out += ` (last ${line1.lastRegistrationAt})`;
out += '\n';
}
const uptime = phoneStatus.elapsedTime || '?';
const vlan = network.vlan ? `VLAN ${network.vlan}` : null;
const link = phoneStatus.linkSpeed || neighbor.portSpeed || null;
const netBits = [uptime !== '?' ? `uptime ${uptime}` : null, vlan, link].filter(Boolean);
if (netBits.length) out += ` ${netBits.join(' · ')}\n`;
if (neighbor.switchDevice) {
const swIp = neighbor.switchIp ? ` (${neighbor.switchIp})` : '';
out += ` Switch: ${neighbor.switchDevice} ${neighbor.switchPort || ''}${swIp}\n`;
}
if (parsed.rebootHistory?.length) {
out += ` Last reboot: ${formatReboot(parsed.rebootHistory[0])}\n`;
}
if (download.latestProvisioning) {
const prov = download.latestProvisioning;
const host = prov.url ? hostFromUrl(prov.url) : '?';
const result = prov.succeeded ? 'OK' : (prov.failed ? 'FAILED' : (prov.result || '?'));
out += ` Last resync: ${host} · ${result}\n`;
}
if (download.latestFirmwareUpgrade) {
const fw = download.latestFirmwareUpgrade;
if (fw.succeeded) {
out += ` Firmware upgrade: OK\n`;
} else if (fw.failed) {
out += ` Firmware upgrade: ${fw.result}\n`;
}
}
if (phoneStatus.dot1xStatus) {
out += ` 802.1X: ${phoneStatus.dot1xStatus}${phoneStatus.dot1xProtocol ? ` (${phoneStatus.dot1xProtocol})` : ''}\n`;
}
if (phoneStatus.ledLine1Color) {
out += ` Line 1 LED: ${phoneStatus.ledLine1Color}${phoneStatus.ledLine1Cadence ? ` ${phoneStatus.ledLine1Cadence}` : ''}\n`;
}
if (parsed.sip?.messagesSent || parsed.sip?.messagesRecv) {
out += ` SIP msgs: ${parsed.sip.messagesSent || 0} sent / ${parsed.sip.messagesRecv || 0} recv`;
if (phoneStatus.sipBytesSent) out += ` (${phoneStatus.sipBytesSent}B / ${phoneStatus.sipBytesRecv || 0}B)`;
out += '\n';
}
if (system.webServer) {
out += ` Web server: ${system.webServer}`;
if (system.proxyMode) out += ` · proxy ${system.proxyMode}`;
out += '\n';
}
if (line1?.messageWaiting && line1.messageWaiting !== 'No') {
out += ` Message waiting: ${line1.messageWaiting}\n`;
}
if (line1?.hotelingState && line1.hotelingState !== 'Disabled') {
out += ` Hoteling: ${line1.hotelingState}\n`;
}
if (Array.isArray(verdict.warnings) && verdict.warnings.length > 0) {
for (const w of verdict.warnings) {
out += ` ⚠️ ${w}\n`;
}
}
let out = renderPhoneHeader({ label, ip, device, phoneStatus, neighbor, line1 });
out += renderRegistrationSection(line1, phoneStatus);
out += renderSwitchSection(neighbor);
out += renderProvisioningSection(parsed, download);
out += renderIssuesSection(issues);
if (verbose) {
out += renderVerboseTier(r, parsed, neighbor, mpp.probes);
out += renderVerboseTier(r, parsed, neighbor, mpp.probes, line1);
}
return out;
}
function renderVerboseTier(r, parsed, neighbor, probes) {
function renderPhoneHeader({ label, ip, device, phoneStatus, neighbor, line1 }) {
const icon = headerIconForLine(line1);
const fwShort = shortenFirmware(device.firmware);
let out = `${icon} **${label}** · \`${ip}\`\n`;
out += `_${device.product || '?'} · ${device.mac || '?'} · ${fwShort}_\n`;
const statusLine = formatHeaderStatusLine(phoneStatus, neighbor);
if (statusLine) out += `_${statusLine}_\n`;
return `${out}\n`;
}
function renderRegistrationSection(line1, phoneStatus) {
const bullets = [];
if (line1?.registrationState) {
let reg = `Ext ${line1.index}: ${line1.registrationState}`;
if (line1.lastRegistrationIp) reg += `${line1.lastRegistrationIp}`;
if (line1.lastRegistrationAt) reg += ` (last ${line1.lastRegistrationAt})`;
bullets.push(reg);
}
if (line1 && !isLineRegistered(line1) && phoneStatus.ledLine1Color) {
const led = `${phoneStatus.ledLine1Color}${phoneStatus.ledLine1Cadence ? ` ${phoneStatus.ledLine1Cadence}` : ''}`;
bullets.push(`Line 1 LED: ${led}`);
}
if (line1?.messageWaiting && line1.messageWaiting !== 'No') {
bullets.push(`Message waiting: ${line1.messageWaiting}`);
}
if (line1?.hotelingState && line1.hotelingState !== 'Disabled') {
bullets.push(`Hoteling: ${line1.hotelingState}`);
}
if (bullets.length === 0) return '';
return `**Registration**\n${bullets.map((b) => `${b}`).join('\n')}\n\n`;
}
function renderSwitchSection(neighbor) {
if (!neighbor?.switchDevice) return '';
const swIp = neighbor.switchIp ? ` · \`${neighbor.switchIp}\`` : '';
const port = neighbor.switchPort || '';
return `**Switch**\n${neighbor.switchDevice} · ${port}${swIp}\n\n`;
}
function renderProvisioningSection(parsed, download) {
const bullets = [];
if (parsed.rebootHistory?.length) {
const reboot = formatRebootEntry(parsed.rebootHistory[0]);
if (reboot) bullets.push(`Last reboot: ${reboot}`);
}
const resync = formatProvisioningResync(download.latestProvisioning);
if (resync) bullets.push(resync);
const fw = formatFirmwareUpgrade(download.latestFirmwareUpgrade);
if (fw) bullets.push(fw);
if (bullets.length === 0) return '';
return `**Provisioning**\n${bullets.map((b) => `${b}`).join('\n')}\n\n`;
}
function renderIssuesSection(issues) {
if (!Array.isArray(issues) || issues.length === 0) return '';
return `**Issues**\n${issues.map((i) => `${i}`).join('\n')}\n\n`;
}
function renderVerboseTier(r, parsed, neighbor, probes, line1) {
let out = '';
const otherLines = (parsed.lines || []).filter((l) => l.index !== 1);
for (const line of otherLines) {
out += ` Ext ${line.index}: ${line.registrationState || '?'}\n`;
if (otherLines.length > 0) {
out += '**Extensions**\n';
for (const line of otherLines) {
out += `• Ext ${line.index}: ${line.registrationState || '?'}\n`;
}
out += '\n';
}
if (parsed.sip?.messagesSent || parsed.sip?.messagesRecv) {
const ps = parsed.phoneStatus || {};
out += `**SIP counters**\n${parsed.sip.messagesSent || 0} sent / ${parsed.sip.messagesRecv || 0} recv`;
if (ps.sipBytesSent) out += ` (${ps.sipBytesSent}B / ${ps.sipBytesRecv || 0}B)`;
out += '\n\n';
}
if (isLineRegistered(line1) && line1 && parsed.phoneStatus?.ledLine1Color) {
const ps = parsed.phoneStatus;
const led = `${ps.ledLine1Color}${ps.ledLine1Cadence ? ` ${ps.ledLine1Cadence}` : ''}`;
out += `**LED**\n• Line 1: ${led}\n\n`;
}
const ps = parsed.phoneStatus || {};
if (ps.ipv6Status || ps.ipv6Address) {
out += ` IPv6: ${ps.ipv6Status || '?'} ${ps.ipv6Address || ''}\n`.trimEnd() + '\n';
out += `**IPv6**\n ${ps.ipv6Status || '?'} ${ps.ipv6Address || ''}\n\n`;
}
if (ps.tr069Feature) out += ` TR-069: ${ps.tr069Feature}\n`;
if (ps.tr069Feature) out += `**TR-069**\n ${ps.tr069Feature}\n\n`;
if (ps.multicastRx != null || ps.multicastTx != null) {
out += ` Paging multicast: rx ${ps.multicastRx || 0} / tx ${ps.multicastTx || 0}\n`;
out += `**Paging multicast**\n rx ${ps.multicastRx || 0} / tx ${ps.multicastTx || 0}\n\n`;
}
if (ps.streamingRx != null) out += ` XML streaming rx: ${ps.streamingRx}\n`;
if (ps.streamingRx != null) out += `**XML streaming**\n• rx ${ps.streamingRx}\n\n`;
const counters = neighbor.errorCounters || {};
const counterKeys = Object.keys(counters);
if (counterKeys.length > 0) {
out += ' Ethernet errors:\n';
out += '**Ethernet errors**\n';
for (const k of counterKeys.slice(0, 8)) {
out += ` ${k}: ${counters[k]}\n`;
out += ` ${k}: ${counters[k]}\n`;
}
out += '\n';
}
const probeList = Array.isArray(probes) ? probes : [];
if (probeList.length > 0) {
out += ' Probe paths:\n';
out += ' ```\n';
out += ' STATUS BYTES PATH\n';
out += '**Probe paths**\n```\nSTATUS BYTES PATH\n';
for (const p of probeList) {
const status = p.status == null ? 'ERR' : String(p.status);
out += ` ${status.padEnd(6)} ${String(p.sizeBytes || 0).padEnd(6)} ${p.path}\n`;
out += `${status.padEnd(6)} ${String(p.sizeBytes || 0).padEnd(6)} ${p.path}\n`;
}
out += ' ```\n';
out += '```\n';
}
return out;
}
function shortenFirmware(fw) {
if (!fw || typeof fw !== 'string') return '?';
const m = fw.match(/(sip78xx[^\s]+)/i);
return m ? m[1] : (fw.length > 36 ? `${fw.slice(0, 36)}` : fw);
}
function formatReboot(raw) {
if (!raw) return '?';
const m = raw.match(/^(\w+)\(([^)]+)\)/);
return m ? `${m[1]} ${m[2]}` : raw;
}
function hostFromUrl(url) {
if (!url || typeof url !== 'string') return '?';
try {
const normalized = url.startsWith('http') ? url : `https://${url}`;
const u = new URL(normalized);
return u.hostname;
} catch {
return url.length > 30 ? `${url.slice(0, 30)}` : url;
}
}

View file

@ -0,0 +1,133 @@
// services/renderers/mppPhoneFormatters.js
//
// Display helpers for MPP phone diagnostics follow-up messages.
import { formatMicCertForDisplay } from '../../integrations/cisco-mpp-phone/downloadStatusJson.js';
export function shortenFirmware(fw) {
if (!fw || typeof fw !== 'string') return '?';
const m = fw.match(/(sip78xx[^\s]+)/i);
return m ? m[1] : (fw.length > 36 ? `${fw.slice(0, 36)}` : fw);
}
/** @param {string|null|undefined} elapsedTime */
export function formatUptime(elapsedTime) {
if (!elapsedTime || typeof elapsedTime !== 'string') return null;
const trimmed = elapsedTime.trim();
if (!trimmed) return null;
const dayHour = trimmed.match(/(\d+)\s+days?\s+and\s+(\d+):(\d+):(\d+)/i);
if (dayHour) {
const days = Number(dayHour[1]);
const hours = Number(dayHour[2]);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h`;
return `${Number(dayHour[3])}m`;
}
const hms = trimmed.match(/^(\d+):(\d+):(\d+)$/);
if (hms) {
const hours = Number(hms[1]);
const mins = Number(hms[2]);
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
return trimmed.length > 24 ? `${trimmed.slice(0, 24)}` : trimmed;
}
/** @param {object} phoneStatus @param {object} neighbor */
export function formatLinkSpeed(phoneStatus = {}, neighbor = {}) {
const fromStatus = phoneStatus.linkSpeed || phoneStatus.linkConfig || null;
if (fromStatus && fromStatus !== 'Disabled') return String(fromStatus).trim();
const fromLldp = neighbor.portSpeed ? String(neighbor.portSpeed).trim() : null;
if (fromLldp) {
const compact = fromLldp.replace(/\s+/g, ' ');
if (/^\d+F$/i.test(compact)) return compact.replace(/F$/i, 'M Full');
return compact;
}
return null;
}
export function formatHeaderStatusLine(phoneStatus, neighbor) {
const uptime = formatUptime(phoneStatus?.elapsedTime);
const link = formatLinkSpeed(phoneStatus, neighbor);
const parts = [];
if (uptime) parts.push(`Uptime ${uptime}`);
if (link) parts.push(link);
return parts.length ? parts.join(' · ') : null;
}
export function isLineRegistered(line) {
const state = String(line?.registrationState || '').toLowerCase();
if (!state) return false;
if (state.includes('not registered') || state.includes('fail')) return false;
return state.includes('registered');
}
export function headerIconForLine(line) {
if (!line?.registrationState) return '⚠️';
return isLineRegistered(line) ? '✅' : '❌';
}
export function formatRebootEntry(raw) {
if (!raw) return null;
const m = String(raw).match(/^(\w+)\(([^)]+)\)/);
if (m) return `${m[1]} @ ${m[2]}`;
return String(raw);
}
export function hostFromUrl(url) {
if (!url || typeof url !== 'string') return '?';
try {
const normalized = url.startsWith('http') ? url : `https://${url}`;
return new URL(normalized).hostname;
} catch {
return url.length > 30 ? `${url.slice(0, 30)}` : url;
}
}
export function formatProvisioningResync(entry) {
if (!entry) return null;
const host = entry.url ? hostFromUrl(entry.url) : '?';
const result = entry.succeeded ? 'OK' : (entry.failed ? 'FAILED' : (entry.result || '?'));
return `Resync: ${host} · ${result}`;
}
export function formatFirmwareUpgrade(entry) {
if (!entry) return null;
if (entry.succeeded) return 'Firmware upgrade: OK';
if (entry.failed) return `Firmware upgrade: ${entry.result || 'FAILED'}`;
return null;
}
/**
* @param {object} mpp buildMppProbeView output
* @param {object} parsed
* @returns {string[]}
*/
export function collectIssues(mpp, parsed) {
const issues = [];
const download = mpp?.download;
const mic = formatMicCertForDisplay(download?.micCert);
if (mic) issues.push(mic);
if (download?.latestProvisioning?.failed) {
const host = download.latestProvisioning.url
? hostFromUrl(download.latestProvisioning.url)
: '?';
issues.push(`Provisioning resync failed — ${host}`);
}
if (download?.latestFirmwareUpgrade?.failed) {
issues.push(`Firmware upgrade failed — ${download.latestFirmwareUpgrade.result || 'unknown'}`);
}
const line1 = parsed?.lines?.find((l) => l.index === 1) || parsed?.lines?.[0];
if (line1?.registrationState && !isLineRegistered(line1)) {
issues.push(`Ext ${line1.index}: ${line1.registrationState}`);
}
return issues;
}

View file

@ -1,9 +1,8 @@
// src/services/renderers/phoneStatusRenderer.js
//
// Extracted from commands/phoneStatus.js so the same output can drive
// Extracted from commands/voiceStatus.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.
// comment (via utils/markdownToAdf).
//
// Options
// storeNum (required) header string uses it
@ -29,20 +28,6 @@ import { simpleTimeAgo, formatBytes, formatDisplayTime } from '../../utils/time.
* @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.
* @param {number} [opts.mppFollowUpPhoneCount=0]
* When > 0, emits MPP desk-phone relay diagnostics loading hint.
* @param {boolean} [opts.wanFollowUpEnabled=false]
* When true, emits a "⏳ WAN metrics loading…" line just above the
* footer. Same rationale as `dectFollowUpBaseCount` chat handler
* sets this only when the Prisma SD-WAN site resolves and a
* follow-up is genuinely in-flight; HTTP / Jira surfaces pass
* false and never see the line.
* @returns {string} markdown, whitespace-trimmed and ready to send.
*/
export function renderPhoneStatusMarkdown(data, opts = {}) {
@ -50,12 +35,9 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
storeNum,
detailed = false,
footer = true,
dectFollowUpBaseCount = 0,
mppFollowUpPhoneCount = 0,
wanFollowUpEnabled = false,
} = opts;
let reply = `**Phone Status - Store ${storeNum}**\n\n`;
let reply = `**Voice Status - Store ${storeNum}**\n\n`;
const phones = data.phones?.data || [];
const dectBasestations = data.dectBasestations || [];
@ -141,11 +123,6 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
}
reply += '\n';
});
if (mppFollowUpPhoneCount > 0) {
const n = mppFollowUpPhoneCount;
reply += `_⏳ MPP phone diagnostics loading for ${n} desk phone${n === 1 ? '' : 's'} via relay — a follow-up message will arrive shortly._\n\n`;
}
}
// DECT Basestations
@ -216,29 +193,12 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
});
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`;
}
// WAN follow-up hint — same "silent for HTTP / Jira" rule as the
// DECT hint above. Sits at the bottom because WAN metrics are the
// last section chronologically (arrives after the DECT follow-up
// in most stores).
if (wanFollowUpEnabled) {
reply += `\n_⏳ WAN metrics loading from Prisma SD-WAN — a follow-up message will arrive shortly._\n`;
}
if (footer) {
reply += `\n*Last checked: ${formatDisplayTime()}*`;
}
@ -246,91 +206,3 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
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 ${formatDisplayTime()} 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;
}

View file

@ -1,6 +1,6 @@
// src/services/renderers/wanDiagnosticsRenderer.js
//
// Pure markdown renderer for the /phonestatus WAN follow-up message.
// Pure markdown renderer for the /wanstatus command.
// Takes a `collectSdwanForStore(storeNum)` result and produces the
// section that shows healthscore + per-path LQM + active alarms.
//

View file

@ -0,0 +1,105 @@
// services/voiceDiag/checks/dectBaseRelay.js
//
// Summarises DECT base relay collect results for /voicediag.
export const DECT_BASE_RELAY_STANDARDS = Object.freeze({
healthy: true,
});
export const dectBaseRelayCheck = {
id: 'dectBaseRelay',
label: 'DECT Base (relay)',
requires: ['phoneStatus'],
scope: null,
standards: DECT_BASE_RELAY_STANDARDS,
async run(ctx) {
if (!process.env.DECT_RELAY_AGENT_TOKEN) {
return {
status: 'skipped',
message: 'Relay not configured (`DECT_RELAY_AGENT_TOKEN` unset) — run `/dectdiag` when relay is available.',
details: null,
remediation: null,
};
}
const results = ctx.dectRelayResults;
if (!Array.isArray(results)) {
return {
status: 'skipped',
message: 'DECT relay collect was not run (no bases discovered on 10.x).',
details: null,
remediation: null,
};
}
if (results.length === 0) {
return {
status: 'skipped',
message: 'No DECT bases discovered on 10.x for relay collect.',
details: null,
remediation: null,
};
}
const bases = [];
let errorCount = 0;
for (const r of results) {
const label = r.base?.name || r.base?.mac || '?';
const ip = r.base?.ip || '?';
if (!r.ok) {
errorCount += 1;
bases.push({
label,
ip,
ok: false,
error: r.error?.message || 'collect failed',
});
continue;
}
const verdict = r.verdict || {};
const data = r.data || {};
const warnings = Array.isArray(verdict.warnings) ? verdict.warnings : [];
const powerLoss = (data.rebootLog || []).find((entry) => entry.reasonCode === 80);
const unhealthy = !verdict.healthy;
if (unhealthy) errorCount += 1;
bases.push({
label,
ip,
ok: true,
healthy: verdict.healthy !== false,
uptime: data.time?.operatingTime || null,
warnings,
powerLoss: powerLoss ? { at: powerLoss.at, sequence: powerLoss.sequence } : null,
activeRtp: data.rtp?.current || 0,
});
}
const summary =
`${results.length} DECT base(s) collected via relay. ` +
`${errorCount === 0 ? 'All bases healthy.' : `${errorCount} base(s) need attention.`}`;
const details = { bases, collected: results.length, attentionCount: errorCount };
if (errorCount > 0) {
return {
status: 'error',
message: summary + ' Run `/dectdiag` for full dump and reboot controls.',
details,
remediation: null,
};
}
return {
status: 'ok',
message: summary,
details,
remediation: null,
};
},
};

View file

@ -55,6 +55,8 @@ import { wanAppRtpLossCheck } from './wan/wanAppRtpLoss.js';
import { wanAppRtpJitterCheck } from './wan/wanAppRtpJitter.js';
import { wanAlarmsCheck } from './wan/wanAlarms.js';
import { wanTunnelsCheck } from './wan/wanTunnels.js';
import { mppDeskPhoneRelayCheck } from './mppDeskPhoneRelay.js';
import { dectBaseRelayCheck } from './dectBaseRelay.js';
// Order: user-facing feature signals first (things an operator can
// see from the phone UI), then LAN-side port hygiene, then the WAN
@ -92,6 +94,8 @@ export const CHECKS = [
wanAppRtpLossCheck,
wanAppRtpJitterCheck,
wanAlarmsCheck,
mppDeskPhoneRelayCheck,
dectBaseRelayCheck,
phoneOnlineCheck,
];

View file

@ -0,0 +1,105 @@
// services/voiceDiag/checks/mppDeskPhoneRelay.js
//
// Summarises MPP desk-phone relay probe results for /voicediag.
import { collectIssues } from '../../renderers/mppPhoneFormatters.js';
import { isLineRegistered } from '../../renderers/mppPhoneFormatters.js';
export const MPP_DESK_PHONE_RELAY_STANDARDS = Object.freeze({
registered: true,
noIssues: true,
});
export const mppDeskPhoneRelayCheck = {
id: 'mppDeskPhoneRelay',
label: 'MPP Desk Phone (relay)',
requires: ['phoneStatus'],
scope: null,
standards: MPP_DESK_PHONE_RELAY_STANDARDS,
async run(ctx) {
if (!process.env.DECT_RELAY_AGENT_TOKEN) {
return {
status: 'skipped',
message: 'Relay not configured (`DECT_RELAY_AGENT_TOKEN` unset) — run `/phonediag` when relay is available.',
details: null,
remediation: null,
};
}
const results = ctx.mppRelayResults;
if (!Array.isArray(results)) {
return {
status: 'skipped',
message: 'MPP relay probe was not run (no desk phones discovered on 10.x).',
details: null,
remediation: null,
};
}
if (results.length === 0) {
return {
status: 'skipped',
message: 'No MPP desk phones discovered on 10.x for relay probe.',
details: null,
remediation: null,
};
}
const phones = [];
let errorCount = 0;
for (const r of results) {
const label = r.phone?.name || r.phone?.product || r.phone?.mac || '?';
const ip = r.phone?.ip || '?';
if (!r.ok) {
errorCount += 1;
phones.push({
label,
ip,
ok: false,
error: r.error?.message || 'probe failed',
});
continue;
}
const parsed = r.mpp?.parsed || r.parsed || {};
const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0];
const issues = collectIssues(r.mpp || {}, parsed);
const registered = line1 ? isLineRegistered(line1) : null;
if (!registered || issues.length > 0) errorCount += 1;
phones.push({
label,
ip,
ok: true,
registered: line1?.registrationState || null,
issues,
});
}
const summary =
`${results.length} MPP phone(s) probed via relay. ` +
`${errorCount === 0 ? 'All registered with no issues.' : `${errorCount} phone(s) need attention.`}`;
const details = { phones, probed: results.length, attentionCount: errorCount };
if (errorCount > 0) {
return {
status: 'error',
message: summary + ' Run `/phonediag` for full section layout.',
details,
remediation: null,
};
}
return {
status: 'ok',
message: summary,
details,
remediation: null,
};
},
};

View file

@ -46,7 +46,7 @@ export function maybeSkippedByKillSwitch(check) {
// Defaults match the ITU-T G.114 / RFC 3550 references for voice
// quality. Any of these can be overridden per-tenant via env; the
// renderer + the checks read from the same accessors so the icon
// in the phonestatus follow-up always agrees with the verdict in
// in the /wanstatus renderer always agrees with the verdict in
// voicediag.
function num(envKey, fallback) {

View file

@ -120,6 +120,39 @@ export async function buildContext(storeNum, opts = {}) {
const phoneStatus = phoneStatusRes.status === 'fulfilled' ? phoneStatusRes.value : null;
const sdwanData = sdwanRes.status === 'fulfilled' ? sdwanRes.value : null;
let dectRelayResults = null;
let mppRelayResults = null;
if (phoneStatus && process.env.DECT_RELAY_AGENT_TOKEN) {
try {
const { discoverDectBases } = await import('../dectDiscovery.js');
const { discoverDeskPhones } = await import('../phoneDiscovery.js');
const { collectAll } = await import('../dectCollectorService.js');
const { probeAll } = await import('../phoneCollectorService.js');
const { bases } = discoverDectBases(phoneStatus);
const { phones } = discoverDeskPhones(phoneStatus);
const [dectRes, mppRes] = await Promise.allSettled([
bases.length > 0 ? collectAll(bases) : Promise.resolve([]),
phones.length > 0 ? probeAll(phones) : Promise.resolve([]),
]);
dectRelayResults = dectRes.status === 'fulfilled' ? dectRes.value : [];
mppRelayResults = mppRes.status === 'fulfilled' ? mppRes.value : [];
if (dectRes.status === 'rejected') {
logger('voicediag', `DECT relay collect soft-failed: ${dectRes.reason?.message}`, 'warn');
}
if (mppRes.status === 'rejected') {
logger('voicediag', `MPP relay probe soft-failed: ${mppRes.reason?.message}`, 'warn');
}
} catch (err) {
logger('voicediag', `Relay probe setup failed: ${err.message}`, 'warn');
dectRelayResults = [];
mppRelayResults = [];
}
}
const personLabel = person?.displayName || email;
return {
@ -143,6 +176,8 @@ export async function buildContext(storeNum, opts = {}) {
// (or when the Prisma integration hasn't been configured).
sdwanData,
sdwanSite: sdwanData?.site || null,
dectRelayResults,
mppRelayResults,
};
}

View file

@ -20,6 +20,6 @@ test('parseDownloadStatusJson: extracts provisioning and firmware history', () =
test('downloadStatusWarnings: flags MIC cert failure', () => {
const parsed = parseDownloadStatusJson(FIXTURE);
const warnings = downloadStatusWarnings(parsed);
assert.ok(warnings.some((w) => /MIC cert/i.test(w)));
assert.ok(warnings.includes('mic_cert_failed'));
assert.equal(parsed.micCert.failed, true);
});

View file

@ -18,7 +18,7 @@ test('extractStoreFromSummary parses Store NNNN', () => {
assert.equal(extractStoreFromSummary('no store here'), null);
});
test('comm call quality runs phonestatus and callreport', () => {
test('comm call quality runs voicestatus and callreport', () => {
const plan = resolveEnrichmentPlan(
{
summary: 'Store 2477 - garbled voice on inbound calls',
@ -28,7 +28,7 @@ test('comm call quality runs phonestatus and callreport', () => {
{ kind: 'phone', storeNum: '2477', reason: 'desk phone issue' },
);
assert.equal(plan.skip, false);
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus, CHECK_IDS.callreport]);
assert.deepEqual(plan.checks, [CHECK_IDS.voicestatus, CHECK_IDS.callreport]);
assert.ok(plan.matchedRules.includes('comm-call-quality'));
});
@ -45,7 +45,7 @@ test('comm spam runs callreport only', () => {
assert.ok(plan.matchedRules.includes('comm-spam'));
});
test('comm spam with both symptoms runs phonestatus and callreport', () => {
test('comm spam with both symptoms runs voicestatus and callreport', () => {
const plan = resolveEnrichmentPlan(
{
summary: 'Store 100 - spam and garbled audio',
@ -53,7 +53,7 @@ test('comm spam with both symptoms runs phonestatus and callreport', () => {
},
{ kind: 'phone', storeNum: '100', reason: 'x' },
);
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus, CHECK_IDS.callreport]);
assert.deepEqual(plan.checks, [CHECK_IDS.voicestatus, CHECK_IDS.callreport]);
});
test('mobility spam does not trigger comm spam rule', () => {
@ -64,11 +64,11 @@ test('mobility spam does not trigger comm spam rule', () => {
},
{ kind: 'phone', storeNum: '500', reason: 'phone' },
);
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus]);
assert.deepEqual(plan.checks, [CHECK_IDS.voicestatus]);
assert.deepEqual(plan.matchedRules, ['default-phone']);
});
test('default phone kind runs phonestatus only', () => {
test('default phone kind runs voicestatus only', () => {
const plan = resolveEnrichmentPlan(
{
summary: 'Store 300 - phone not registering',
@ -76,7 +76,7 @@ test('default phone kind runs phonestatus only', () => {
},
{ kind: 'phone', storeNum: '300', reason: 'registration' },
);
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus]);
assert.deepEqual(plan.checks, [CHECK_IDS.voicestatus]);
assert.deepEqual(plan.matchedRules, ['default-phone']);
});

View file

@ -16,7 +16,7 @@ test('formatEnrichmentBody merges sections with headings', () => {
});
test('CHECK_IDS covers expected checks', () => {
assert.equal(CHECK_IDS.phonestatus, 'phonestatus');
assert.equal(CHECK_IDS.voicestatus, 'voicestatus');
assert.equal(CHECK_IDS.callreport, 'callreport');
assert.equal(CHECK_IDS.avstatus, 'avstatus');
});

View file

@ -5,6 +5,14 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildMppProbeView } from '../integrations/cisco-mpp-phone/aggregateProbe.js';
import { formatMicCertForDisplay } from '../integrations/cisco-mpp-phone/downloadStatusJson.js';
import { parseDownloadStatusJson, downloadStatusWarnings } from '../integrations/cisco-mpp-phone/downloadStatusJson.js';
import {
formatUptime,
formatHeaderStatusLine,
headerIconForLine,
isLineRegistered,
} from '../services/renderers/mppPhoneFormatters.js';
import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -34,22 +42,63 @@ function okResult() {
};
}
test('renderMppPhoneDiagnosticsMarkdown: tier 1+2 default output', () => {
test('formatMicCertForDisplay: short message without URL', () => {
const parsed = parseDownloadStatusJson(DOWNLOAD);
const line = formatMicCertForDisplay(parsed.micCert);
assert.match(line, /MIC cert download failed — .*File not found/i);
assert.doesNotMatch(line, /sudirenewal\.cisco\.com/);
assert.match(line, /07\/28\/2026/);
});
test('formatUptime: compresses day-hour strings', () => {
assert.equal(formatUptime('22 days and 07:20:35'), '22d 7h');
assert.equal(formatUptime('2 days and 12:01:51'), '2d 12h');
});
test('headerIconForLine: registered vs not', () => {
assert.equal(headerIconForLine({ registrationState: 'Registered' }), '✅');
assert.equal(headerIconForLine({ registrationState: 'Not Registered' }), '❌');
assert.equal(isLineRegistered({ registrationState: 'Registered' }), true);
});
test('renderMppPhoneDiagnosticsMarkdown: section layout default tier', () => {
const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false });
assert.match(md, /\*\*MPP Desk Phone Diagnostics — Store 782\*\*/);
assert.match(md, /\*\*Registration\*\*/);
assert.match(md, /\*\*Switch\*\*/);
assert.match(md, /\*\*Provisioning\*\*/);
assert.match(md, /\*\*Issues\*\*/);
assert.doesNotMatch(md, /\*\*Network\*\*/);
assert.match(md, /_Uptime 22d 7h · 100M Full_/);
assert.match(md, /✅ \*\*Store 00782 CP-7841\*\*/);
assert.match(md, /Registered/);
assert.match(md, /SW00782R/);
assert.match(md, /Port 45/);
assert.match(md, /cisco\.sipflash\.com/);
assert.match(md, /MIC cert/i);
assert.doesNotMatch(md, /Probe paths:/);
assert.match(md, /Resync: cisco\.sipflash\.com · OK/);
assert.match(md, /MIC cert download failed — .*File not found/i);
assert.doesNotMatch(md, /sudirenewal\.cisco\.com/);
assert.doesNotMatch(md, /802\.1X/i);
assert.doesNotMatch(md, /Web server/i);
assert.doesNotMatch(md, /VLAN/i);
assert.doesNotMatch(md, /proxy/i);
assert.doesNotMatch(md, /Probe paths/);
assert.doesNotMatch(md, /SIP counters/);
});
test('renderMppPhoneDiagnosticsMarkdown: verbose includes probe table', () => {
test('renderMppPhoneDiagnosticsMarkdown: verbose includes probe table and SIP', () => {
const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', verbose: true, footer: false });
assert.match(md, /Probe paths:/);
assert.match(md, /\*\*Probe paths\*\*/);
assert.match(md, /\/Status\.json/);
assert.match(md, /\*\*Extensions\*\*/);
assert.match(md, /Ext 2:/);
assert.match(md, /SIP counters/);
});
test('renderMppPhoneDiagnosticsMarkdown: unregistered shows X header', () => {
const r = okResult();
r.mpp.parsed.lines[0].registrationState = 'Not Registered';
const md = renderMppPhoneDiagnosticsMarkdown([r], { storeNum: '782', footer: false });
assert.match(md, /❌ \*\*Store 00782 CP-7841\*\*/);
});
test('renderMppPhoneDiagnosticsMarkdown: relay failure line', () => {
@ -69,5 +118,13 @@ test('buildMppProbeView: merges MIC warning into verdict', () => {
nsJson: NS,
});
assert.equal(mpp.verdict.healthy, false);
assert.ok(mpp.verdict.warnings.some((w) => /MIC cert/i.test(w)));
assert.ok(mpp.verdict.warnings.includes('mic_cert_failed'));
});
test('formatHeaderStatusLine: uptime and link from phone status', () => {
const line = formatHeaderStatusLine(
{ elapsedTime: '22 days and 07:20:35', linkSpeed: '100M Full' },
{},
);
assert.equal(line, 'Uptime 22d 7h · 100M Full');
});

View file

@ -12,7 +12,6 @@ import assert from 'node:assert/strict';
import {
renderPhoneStatusMarkdown,
renderDectDiagnosticsMarkdown,
} from '../services/renderers/phoneStatusRenderer.js';
import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js';
import { renderDectStatusMarkdown } from '../services/renderers/dectStatusRenderer.js';
@ -24,7 +23,7 @@ const threeHrAgo = () => new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()
test('phone renderer: header and empty-store message', () => {
const md = renderPhoneStatusMarkdown({}, { storeNum: '782' });
assert.match(md, /^\*\*Phone Status - Store 782\*\*/);
assert.match(md, /^\*\*Voice Status - Store 782\*\*/);
assert.match(md, /No phones or DECT basestations found/);
});
@ -255,173 +254,7 @@ test('av renderer: Atlas AMP with vitals renders temps + fan + amps', () => {
});
// ─────────────────────────────────────────────────────────────
// DECT follow-up "loading" hint (main /phonestatus output)
// ─────────────────────────────────────────────────────────────
test('phone renderer: dectFollowUpBaseCount > 0 emits a loading hint inside the DECT section', () => {
const data = {
dectBasestations: [
{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } },
{ mac: 'aa:bb:cc:dd:ee:02', meraki: { status: 'Online' } },
],
dectHandsets: [],
};
const md = renderPhoneStatusMarkdown(data, {
storeNum: '782', footer: false, dectFollowUpBaseCount: 2,
});
assert.match(md, /Base-station diagnostics loading for 2 bases/);
});
test('phone renderer: dectFollowUpBaseCount === 1 uses singular "base"', () => {
const data = {
dectBasestations: [{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }],
dectHandsets: [],
};
const md = renderPhoneStatusMarkdown(data, {
storeNum: '782', footer: false, dectFollowUpBaseCount: 1,
});
assert.match(md, /loading for 1 base —/);
});
test('phone renderer: dectFollowUpBaseCount === 0 emits no loading hint (default state)', () => {
const data = {
dectBasestations: [{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }],
dectHandsets: [],
};
const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false });
assert.doesNotMatch(md, /diagnostics loading/);
});
test('phone renderer: mppFollowUpPhoneCount > 0 emits MPP loading hint', () => {
const data = {
phones: { data: [{ name: 'Store CP-7841', status: 'connected', lastSeen: new Date().toISOString() }] },
dectBasestations: [],
dectHandsets: [],
};
const md = renderPhoneStatusMarkdown(data, {
storeNum: '782', footer: false, mppFollowUpPhoneCount: 1,
});
assert.match(md, /MPP phone diagnostics loading for 1 desk phone/);
});
// ─────────────────────────────────────────────────────────────
// DECT follow-up message (renderDectDiagnosticsMarkdown)
// ─────────────────────────────────────────────────────────────
const okResult = (overrides = {}) => ({
base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' },
ok: true,
data: {
time: { operatingTime: '02:15:00 (H:M:S)' },
firmware: { version: '05-01-03-0101-09' },
multiCell: { role: 'primary' },
conflictInfo: 'No Conflict',
rebootLog: [],
rtp: { current: 0 },
},
verdict: { healthy: true, warnings: [], info: [] },
elapsedMs: 812,
...overrides,
});
test('dect diagnostics renderer: empty input returns empty string (caller should not send)', () => {
assert.equal(renderDectDiagnosticsMarkdown([], { storeNum: '782' }), '');
assert.equal(renderDectDiagnosticsMarkdown(null, { storeNum: '782' }), '');
});
test('dect diagnostics renderer: healthy base renders check + uptime + firmware', () => {
const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false });
assert.match(md, /\*\*DECT Base Station Diagnostics — Store 782\*\*/);
assert.match(md, /✅ \*\*Basestation A\*\* \(10\.4\.11\.87\)/);
assert.match(md, /uptime 02:15:00/);
assert.match(md, /fw 05-01-03-0101-09/);
assert.match(md, /role: primary/);
});
test('dect diagnostics renderer: warnings from verdict are surfaced under the header', () => {
const md = renderDectDiagnosticsMarkdown([
okResult({
verdict: {
healthy: false,
warnings: ['Rx errors: 42 since last boot'],
info: [],
},
}),
], { storeNum: '782', footer: false });
assert.match(md, /⚠️ \*\*Basestation A\*\*/);
assert.match(md, /⚠️ Rx errors: 42 since last boot/);
});
test('dect diagnostics renderer: recent Power Loss reboot gets its own bolt line + suppresses duplicate warning', () => {
const md = renderDectDiagnosticsMarkdown([
okResult({
data: {
time: { operatingTime: '02:15:00' },
firmware: { version: '05-01-03-0101-09' },
multiCell: { role: 'primary' },
conflictInfo: 'No Conflict',
rebootLog: [
{ sequence: 164, at: '2026-07-02T12:54:12', reasonName: 'Power Loss', reasonCode: 80 },
],
rtp: { current: 0 },
},
verdict: {
healthy: false,
warnings: ['1 recent power-loss reboot(s); most recent at 2026-07-02T12:54:12'],
info: [],
},
}),
], { storeNum: '782', footer: false });
// The structured line survives …
assert.match(md, /⚡ Recent power loss: 2026-07-02T12:54:12 \(reboot #164\)/);
// … but the summary warning about power-loss is filtered out to
// avoid duplication under the same header.
assert.doesNotMatch(md, /⚠️ 1 recent power-loss/);
});
test('dect diagnostics renderer: active RTP session gets a call icon', () => {
const md = renderDectDiagnosticsMarkdown([
okResult({
data: {
time: { operatingTime: '02:15:00' },
firmware: { version: '05-01-03-0101-09' },
multiCell: { role: 'primary' },
conflictInfo: 'No Conflict',
rebootLog: [],
rtp: { current: 2 },
},
}),
], { storeNum: '782', footer: false });
assert.match(md, /📞 2 active RTP session/);
});
test('dect diagnostics renderer: base with error renders remediation hint', () => {
const md = renderDectDiagnosticsMarkdown([
{
base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' },
ok: false,
data: null,
verdict: null,
elapsedMs: 15003,
error: {
code: 'RELAY_RPC_TIMEOUT',
message: 'timed out after 15000ms',
hint: 'Relay accepted the request but the base did not respond in time.',
},
},
], { storeNum: '782', footer: false });
assert.match(md, /⚠️ \*\*Basestation A\*\* \(10\.4\.11\.87\) — collect failed: timed out after 15000ms/);
assert.match(md, /Relay accepted the request but the base did not respond in time\./);
});
test('dect diagnostics renderer: footer references /dectstatus command by store', () => {
const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782' });
assert.match(md, /Use `\/dectstatus 782`/);
});
// ─────────────────────────────────────────────────────────────
// Full /dectstatus dump (renderDectStatusMarkdown)
// Full /dectdiag dump (renderDectStatusMarkdown)
// ─────────────────────────────────────────────────────────────
const fullOkResult = () => ({

View file

@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mppDeskPhoneRelayCheck } from '../services/voiceDiag/checks/mppDeskPhoneRelay.js';
import { dectBaseRelayCheck } from '../services/voiceDiag/checks/dectBaseRelay.js';
const origToken = process.env.DECT_RELAY_AGENT_TOKEN;
test('mppDeskPhoneRelayCheck: skipped when relay token unset', async () => {
delete process.env.DECT_RELAY_AGENT_TOKEN;
const result = await mppDeskPhoneRelayCheck.run({ phoneStatus: {} });
assert.equal(result.status, 'skipped');
assert.match(result.message, /Relay not configured/i);
});
test('mppDeskPhoneRelayCheck: error when phone has registration issues', async () => {
process.env.DECT_RELAY_AGENT_TOKEN = 'test-token';
const result = await mppDeskPhoneRelayCheck.run({
phoneStatus: {},
mppRelayResults: [{
ok: true,
phone: { name: 'Store CP-7841', ip: '10.1.1.1' },
mpp: {
parsed: {
lines: [{ index: 1, registrationState: 'Not Registered' }],
},
},
}],
});
assert.equal(result.status, 'error');
assert.match(result.message, /need attention/i);
if (origToken === undefined) delete process.env.DECT_RELAY_AGENT_TOKEN;
else process.env.DECT_RELAY_AGENT_TOKEN = origToken;
});
test('dectBaseRelayCheck: ok when all bases healthy', async () => {
process.env.DECT_RELAY_AGENT_TOKEN = 'test-token';
const result = await dectBaseRelayCheck.run({
phoneStatus: {},
dectRelayResults: [{
ok: true,
base: { name: 'Base A', ip: '10.2.2.2' },
data: { time: { operatingTime: '1d' }, rebootLog: [], rtp: { current: 0 } },
verdict: { healthy: true, warnings: [] },
}],
});
assert.equal(result.status, 'ok');
assert.match(result.message, /All bases healthy/i);
if (origToken === undefined) delete process.env.DECT_RELAY_AGENT_TOKEN;
else process.env.DECT_RELAY_AGENT_TOKEN = origToken;
});
test('dectBaseRelayCheck: error when base unhealthy', async () => {
process.env.DECT_RELAY_AGENT_TOKEN = 'test-token';
const result = await dectBaseRelayCheck.run({
phoneStatus: {},
dectRelayResults: [{
ok: true,
base: { name: 'Base A', ip: '10.2.2.2' },
data: { time: { operatingTime: '1d' }, rebootLog: [], rtp: { current: 0 } },
verdict: { healthy: false, warnings: ['Rx errors'] },
}],
});
assert.equal(result.status, 'error');
assert.match(result.message, /need attention/i);
if (origToken === undefined) delete process.env.DECT_RELAY_AGENT_TOKEN;
else process.env.DECT_RELAY_AGENT_TOKEN = origToken;
});

11
tests/wanStatus.test.js Normal file
View file

@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseWindowMinutes } from '../utils/windowArgs.js';
test('parseWindowMinutes: shared util resolves durations', () => {
assert.equal(parseWindowMinutes('24h'), 1440);
assert.equal(parseWindowMinutes('7d'), 10080);
assert.equal(parseWindowMinutes(null), undefined);
assert.equal(parseWindowMinutes('bogus'), null);
});

View file

@ -3,7 +3,7 @@
// Narrow, hand-rolled markdown → Atlassian Document Format converter.
//
// Grammar supported (deliberately minimal, matches what our chat
// renderers actually emit — see commands/phoneStatus.js and
// renderers actually emit — see commands/voiceStatus.js and
// commands/avStatus.js):
//
// **text** → { type:'text', text, marks:[{type:'strong'}] }

37
utils/windowArgs.js Normal file
View file

@ -0,0 +1,37 @@
// utils/windowArgs.js
//
// Shared `--window` parsing for /wanstatus and /voicediag.
export function pickWindowArg(args) {
return pickFlagValue(args, '--window');
}
function pickFlagValue(args, flag) {
const prefix = `${flag.toLowerCase()}=`;
for (let i = 0; i < args.length; i += 1) {
const a = String(args[i] || '').toLowerCase();
if (a.startsWith(prefix)) return String(args[i]).slice(prefix.length);
if (a === flag.toLowerCase() && i + 1 < args.length) return args[i + 1];
}
return null;
}
/**
* Parse a window shorthand (`15m`, `1h`, `24h`, `1d`, `1440`) into
* minutes. Returns null on unrecognised input so the caller can
* decide whether to surface a friendly error or fall back to the
* env default.
*/
export function parseWindowMinutes(raw) {
if (raw === null || raw === undefined || raw === '') return undefined;
const s = String(raw).trim().toLowerCase();
if (/^\d+$/.test(s)) return Math.max(1, parseInt(s, 10));
const m = s.match(/^(\d+)\s*(m|min|mins|h|hr|hrs|hour|hours|d|day|days)$/);
if (!m) return null;
const n = parseInt(m[1], 10);
const unit = m[2];
if (['m', 'min', 'mins'].includes(unit)) return n;
if (['h', 'hr', 'hrs', 'hour', 'hours'].includes(unit)) return n * 60;
if (['d', 'day', 'days'].includes(unit)) return n * 60 * 24;
return null;
}