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 // /dectdiag <store> — full DECT basestation diagnostics via the
// on-prem relay agent. Complements the compact // on-prem relay agent: status.xml dump (device,
// follow-up that /phonestatus already posts: // firmware, reboot log, network, RTP) plus chat-only
// this is the full status.xml dump (device, // action cards for reboot / force-reboot / factory-reset.
// 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.
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { collectPhoneStatus } from '../services/phoneService.js'; import { collectPhoneStatus } from '../services/phoneService.js';
@ -71,7 +53,7 @@ export const DECT_STATUS_CARD_ACTIONS = new Set([
CANCEL_ACTION, CANCEL_ACTION,
]); ]);
export async function handleDectStatus(bot, trigger) { export async function handleDectDiag(bot, trigger) {
const query = trigger.query || {}; const query = trigger.query || {};
const args = trigger.args || []; const args = trigger.args || [];
const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim(); 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( await bot.say(
'markdown', 'markdown',
'Please provide a 24 digit store number.\n' + 'Please provide a 24 digit store number.\n' +
'Example: `/dectstatus 782`\n' + 'Example: `/dectdiag 782`\n' +
'HTTP: `?storeNum=782`', 'HTTP: `?storeNum=782`',
); );
return; return;
} }
// Optional single-base filter: second arg as IP or MAC.
const filterRaw = (args[1] || query.base || query.ip || query.mac || '').trim(); 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})` : '')); (filterRaw ? ` (filter=${filterRaw})` : ''));
// Relay status snapshot for the header — cheap, no I/O.
let relayStatus = null; let relayStatus = null;
try { try {
if (process.env.DECT_RELAY_AGENT_TOKEN) { if (process.env.DECT_RELAY_AGENT_TOKEN) {
relayStatus = getDectRelayHub().status(); relayStatus = getDectRelayHub().status();
} }
} catch (err) { } 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) { if (!process.env.DECT_RELAY_AGENT_TOKEN) {
await bot.say( await bot.say(
'markdown', 'markdown',
'⚠️ DECT relay is not configured on this bot (`DECT_RELAY_AGENT_TOKEN` unset). ' + '⚠️ 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; return;
} }
@ -115,7 +95,7 @@ export async function handleDectStatus(bot, trigger) {
try { try {
phoneData = await collectPhoneStatus(storeNum); phoneData = await collectPhoneStatus(storeNum);
} catch (err) { } 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}`); await bot.say('markdown', `❌ Failed to look up store ${storeNum}: ${err.message}`);
return; return;
} }
@ -139,7 +119,7 @@ export async function handleDectStatus(bot, trigger) {
await bot.say( await bot.say(
'markdown', 'markdown',
targets.length 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}`, : `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.'); await bot.say('markdown', md || 'No data available.');
// Action cards are chat-only (need adaptive-card UX + room).
if (trigger.person) { if (trigger.person) {
const okBases = results.filter((r) => r.ok && r.base?.ip); const okBases = results.filter((r) => r.ok && r.base?.ip);
for (const r of okBases) { for (const r of okBases) {
@ -168,7 +147,7 @@ export async function handleDectStatus(bot, trigger) {
}); });
} catch (err) { } catch (err) {
logger( logger(
'dect:status', 'dect:diag',
`Failed to post action card for ${r.base?.ip}: ${err.message}`, `Failed to post action card for ${r.base?.ip}: ${err.message}`,
'warn', 'warn',
); );
@ -192,7 +171,7 @@ export async function handleDectStatusAction(bot, trigger) {
const dectAction = inputs.dectAction; const dectAction = inputs.dectAction;
const meta = DECT_STATUS_ACTIONS[dectAction]; const meta = DECT_STATUS_ACTIONS[dectAction];
if (!meta) { if (!meta) {
logger('dect:status:action', `Unknown dectAction "${dectAction}" — ignoring`, 'warn'); logger('dect:diag:action', `Unknown dectAction "${dectAction}" — ignoring`, 'warn');
return; return;
} }
const storeNum = String(inputs.storeNum || '').trim(); const storeNum = String(inputs.storeNum || '').trim();
@ -200,7 +179,7 @@ export async function handleDectStatusAction(bot, trigger) {
const baseMac = String(inputs.baseMac || '').trim(); const baseMac = String(inputs.baseMac || '').trim();
const baseName = String(inputs.baseName || baseIp || 'base').trim(); const baseName = String(inputs.baseName || baseIp || 'base').trim();
if (!storeNum || !baseIp) { if (!storeNum || !baseIp) {
logger('dect:status:action', 'request missing storeNum/baseIp — ignoring', 'warn'); logger('dect:diag:action', 'request missing storeNum/baseIp — ignoring', 'warn');
return; return;
} }
@ -240,17 +219,17 @@ export async function handleDectStatusAction(bot, trigger) {
if (actionType === CONFIRM_ACTION || actionType === CANCEL_ACTION) { if (actionType === CONFIRM_ACTION || actionType === CANCEL_ACTION) {
const { cardId } = inputs; const { cardId } = inputs;
if (!cardId) { if (!cardId) {
logger('dect:status:action', `Missing cardId on ${actionType} — ignoring`); logger('dect:diag:action', `Missing cardId on ${actionType} — ignoring`);
return; return;
} }
if (!pendingDectActions.has(cardId)) { if (!pendingDectActions.has(cardId)) {
logger('dect:status:action', `Card ${cardId} is expired or unknown`); logger('dect:diag:action', `Card ${cardId} is expired or unknown`);
await bot.say('markdown', '⚠️ That action card has expired. Re-run `/dectstatus` and try again.'); await bot.say('markdown', '⚠️ That action card has expired. Re-run `/dectdiag` and try again.');
return; return;
} }
const data = pendingDectActions.get(cardId); const data = pendingDectActions.get(cardId);
pendingDectActions.delete(cardId); // one-shot pendingDectActions.delete(cardId);
if (actionType === CANCEL_ACTION) { if (actionType === CANCEL_ACTION) {
await bot.say( await bot.say(
@ -265,7 +244,6 @@ export async function handleDectStatusAction(bot, trigger) {
return; return;
} }
// CONFIRM
const meta = DECT_STATUS_ACTIONS[data.dectAction] || { const meta = DECT_STATUS_ACTIONS[data.dectAction] || {
id: data.dectAction, id: data.dectAction,
label: data.dectAction, label: data.dectAction,
@ -290,7 +268,7 @@ export async function handleDectStatusAction(bot, trigger) {
'markdown', 'markdown',
`✅ **${meta.label}** issued on **${data.baseName}** (${data.baseIp})` + `✅ **${meta.label}** issued on **${data.baseName}** (${data.baseIp})` +
(result.elapsedMs != null ? ` in ${result.elapsedMs}ms` : '') + (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( logger(
'dect:audit', 'dect:audit',
@ -312,12 +290,10 @@ export async function handleDectStatusAction(bot, trigger) {
return; return;
} }
logger('dect:status:action', `Ignoring unhandled action type ${actionType}`, 'debug'); logger('dect:diag:action', `Ignoring unhandled action type ${actionType}`, 'debug');
void roomId; void roomId;
} }
// ─── Cards ──────────────────────────────────────────────────────────
function buildBaseActionCard({ storeNum, base }) { function buildBaseActionCard({ storeNum, base }) {
const actions = Object.values(DECT_STATUS_ACTIONS).map((meta) => ({ const actions = Object.values(DECT_STATUS_ACTIONS).map((meta) => ({
type: 'Action.Submit', type: 'Action.Submit',
@ -402,8 +378,6 @@ function buildConfirmCard({ cardId, storeNum, baseName, baseIp, meta }) {
}; };
} }
// ─── Helpers ────────────────────────────────────────────────────────
function filterBases(bases, raw) { function filterBases(bases, raw) {
const needle = String(raw).trim().toLowerCase(); const needle = String(raw).trim().toLowerCase();
const needleMac = needle.replace(/[^0-9a-f]/g, ''); const needleMac = needle.replace(/[^0-9a-f]/g, '');

View file

@ -18,9 +18,11 @@ const SHORT_HELP = {
// AV / phones // AV / phones
avstatus: 'AV / device status for a store (alias: /wostatus)', avstatus: 'AV / device status for a store (alias: /wostatus)',
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)', voicestatus: 'Quick DECT + IP phone status for a store',
dectstatus: 'Full DECT base dump via relay (handsets, RSSI, reboot cards)', wanstatus: 'Prisma SD-WAN health + voice traffic quality',
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards', 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)', 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)', 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`.', 'Includes Meraki deep links per device. For interactive topology, open `/av-store-dashboard.html`.',
], ],
}, },
phonestatus: { voicestatus: {
title: '/phonestatus', title: '/voicestatus',
usage: [ usage: [
'/phonestatus <store>', '/voicestatus <store>',
'/phonestatus <store> detailed', '/voicestatus <store> detailed',
'/phonestatus <store> verbose',
'/phonestatus <store> detailed verbose',
], ],
examples: [ examples: [
'/phonestatus 782', '/voicestatus 782',
'/phonestatus 782 detailed', '/voicestatus 782 detailed',
'/phonestatus 782 verbose',
'/phonestatus 782 debug',
], ],
notes: [ notes: [
'Shows DECT basestations + IP phones with Meraki links.', 'Shows DECT basestations + IP phones with Meraki links — quick scan only, no follow-up messages.',
'Detailed mode adds firmware, serial, SIP details and errors (main message only).', 'Detailed mode adds firmware, serial, SIP details and errors.',
'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.', 'For MPP desk phone relay diagnostics, use `/phonediag <store>`.',
'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).', 'For Prisma SD-WAN metrics, use `/wanstatus <store>`.',
'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 full DECT base dump + reboot/factory-reset controls, use `/dectdiag <store>`.',
'For the full DECT base dump + reboot/factory-reset controls, use `/dectstatus <store>`.',
'For an in-depth voice diagnostic with per-user Webex Calling checks + fix cards, use `/voicediag <store>`.', 'For an in-depth voice diagnostic with per-user Webex Calling checks + fix cards, use `/voicediag <store>`.',
'Web dashboard: `/phone-store-dashboard.html`.', 'Web dashboard: `/phone-store-dashboard.html`.',
], ],
}, },
dectstatus: { wanstatus: {
title: '/dectstatus', title: '/wanstatus',
usage: [ usage: [
'/dectstatus <store>', '/wanstatus <store>',
'/dectstatus <store> <ip|mac>', '/wanstatus <store> --window 24h',
], ],
examples: [ examples: [
'/dectstatus 782', '/wanstatus 782',
'/dectstatus 782 10.12.34.56', '/wanstatus 782 --window 7d',
'/dectstatus 782 6c:ab:05:12:34:56', ],
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: [ notes: [
'Aliases: `/dect`.', '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.', '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.', '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`.', '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).', '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: { voicediag: {
@ -119,7 +149,7 @@ const LONG_HELP = {
'/voicediag list-checks', '/voicediag list-checks',
], ],
notes: [ 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.', '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.', '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.', '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'], examples: ['/jirapoll', '/jirapoll prime'],
notes: [ 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.', '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.', '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.', '`/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.', '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 = [ const GROUPS = [
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] }, { 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: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] }, { title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] }, { title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },

View file

@ -2,7 +2,7 @@
// //
// Adaptive-card inline remediation for Meraki switch multicast policy // Adaptive-card inline remediation for Meraki switch multicast policy
// on a store's network. Not a standalone chat command — the card is // 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 // collector reports `data.multicast.needsFix`. Confirm and Cancel
// flow through the framework's `attachmentAction` event dispatched // flow through the framework's `attachmentAction` event dispatched
// from index.js, which follows the same pending-card / one-shot / // 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 * reply when multicast policy deviates from DECT-safe defaults. The
* card renders whatever the summarizer flagged as deviating so the * card renders whatever the summarizer flagged as deviating so the
* clicker knows exactly what will change. * clicker knows exactly what will change.
@ -150,7 +150,7 @@ export async function applyDectSafeMulticast(bot, data, _roomId, requester) {
'markdown', 'markdown',
`✅ Multicast pinned to DECT-safe defaults on **${networkName || `store ${storeNum}`}**: ` + `✅ Multicast pinned to DECT-safe defaults on **${networkName || `store ${storeNum}`}**: ` +
`IGMP snoop=OFF, flood-unknown=ON, all switch overrides cleared. ` + `IGMP snoop=OFF, flood-unknown=ON, all switch overrides cleared. ` +
`Re-run \`/phonestatus ${storeNum}\` to verify.`, `Re-run \`/voicestatus ${storeNum}\` to verify.`,
); );
logger( logger(
'igmp:audit', '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 { handleHelp } from './help.js';
import { handleAvStatus } from './avStatus.js'; import { handleAvStatus } from './avStatus.js';
import { handlePhoneStatus } from './phoneStatus.js'; import { handleVoiceStatus } from './voiceStatus.js';
import { handleDectStatus } from './dectStatus.js'; import { handleWanStatus } from './wanStatus.js';
import { handlePhoneDiag } from './phoneDiag.js';
import { handleDectDiag } from './dectDiag.js';
import { handleProvisionDect } from './provisionDect.js'; import { handleProvisionDect } from './provisionDect.js';
import { handleWoHistory } from './woHistory.js'; import { handleWoHistory } from './woHistory.js';
import { handleWoSummary } from './woSummary.js'; import { handleWoSummary } from './woSummary.js';
@ -46,11 +48,13 @@ export const commands = [
{ name: 'help', handler: handleHelp, mutating: false, http: false }, { name: 'help', handler: handleHelp, mutating: false, http: false },
{ name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false }, { name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false },
{ name: 'phonestatus', handler: handlePhoneStatus, mutating: false }, { name: 'voicestatus', handler: handleVoiceStatus, mutating: false },
// /dectstatus — full DECT base dump via the on-prem relay + chat-only { 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 // reboot/factory-reset cards. Read path is non-mutating; card submits
// only fire over Webex (see commands/dectStatus.js). // only fire over Webex (see commands/dectDiag.js).
{ name: 'dectstatus', aliases: ['dect'], handler: handleDectStatus, mutating: false }, { name: 'dectdiag', aliases: ['dect'], handler: handleDectDiag, mutating: false },
// /voicediag reads per-user Webex Calling features via // /voicediag reads per-user Webex Calling features via
// /v1/people/{id}/features/* and offers per-issue adaptive-card // /v1/people/{id}/features/* and offers per-issue adaptive-card
// remediation. Reads are non-mutating but the confirm buttons on // remediation. Reads are non-mutating but the confirm buttons on

View file

@ -33,6 +33,9 @@ import {
buildRemediationRegistry, buildRemediationRegistry,
CHECKS, CHECKS,
} from '../services/voiceDiag/voiceDiagService.js'; } from '../services/voiceDiag/voiceDiagService.js';
import { pickWindowArg, parseWindowMinutes } from '../utils/windowArgs.js';
export { parseWindowMinutes };
// Built once at module load. `buildRemediationRegistry` throws on // Built once at module load. `buildRemediationRegistry` throws on
// duplicate action ids, so any collision surfaces at import time — // 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; let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
// Accept every reasonable variant so the footer hint ("pass // 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 // the second positional) both work, alongside the flag forms
// (`--detail`, `--detailed`) and the `?detailed=true` query param. // (`--detail`, `--detailed`) and the `?detailed=true` query param.
const detailed = argIncludes(args, 'detail') 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 // Adaptive-card remediation is chat-only. HTTP callers get the
// markdown snapshot but no interactive cards (same rule 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. // is populated for chat, absent for the HTTP fake trigger.
if (!trigger.person) return; if (!trigger.person) return;
@ -348,10 +351,6 @@ function pickOnlyArg(args) {
return pickFlagValue(args, '--only'); return pickFlagValue(args, '--only');
} }
function pickWindowArg(args) {
return pickFlagValue(args, '--window');
}
// Generic `--flag=value` / `--flag value` extractor. // Generic `--flag=value` / `--flag value` extractor.
function pickFlagValue(args, flag) { function pickFlagValue(args, flag) {
const prefix = `${flag.toLowerCase()}=`; const prefix = `${flag.toLowerCase()}=`;
@ -363,27 +362,6 @@ function pickFlagValue(args, flag) {
return null; 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() { function renderListChecks() {
let md = '**/voicediag registered checks**\n\n'; let md = '**/voicediag registered checks**\n\n';
for (const c of CHECKS) { 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 { import {
handleDectStatusAction, handleDectStatusAction,
DECT_STATUS_CARD_ACTIONS, DECT_STATUS_CARD_ACTIONS,
} from './commands/dectStatus.js'; } from './commands/dectDiag.js';
import { import {
applyOffboardConfirmation, applyOffboardConfirmation,
cancelOffboardCard, cancelOffboardCard,
@ -840,7 +840,7 @@ if (process.env.DECT_RELAY_AGENT_TOKEN) {
} else { } else {
logger( logger(
'startup', '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', 'warn',
); );
} }

View file

@ -78,13 +78,37 @@ export function downloadStatusWarnings(download) {
const warnings = []; const warnings = [];
if (!download) return warnings; if (!download) return warnings;
if (download.micCert?.failed) { if (download.micCert?.failed) {
warnings.push(`MIC cert: ${download.micCert.provisioningStatus}`); warnings.push('mic_cert_failed');
} }
if (download.latestProvisioning?.failed) { if (download.latestProvisioning?.failed) {
warnings.push(`provisioning: ${download.latestProvisioning.result}`); warnings.push('provisioning_failed');
} }
if (download.latestFirmwareUpgrade?.failed) { if (download.latestFirmwareUpgrade?.failed) {
warnings.push(`firmware upgrade: ${download.latestFirmwareUpgrade.result}`); warnings.push('firmware_upgrade_failed');
} }
return warnings; 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 // 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 * @param {object|null} phoneData collectPhoneStatus() output

View file

@ -1,6 +1,6 @@
// src/services/enrichment/sdwanEnrichment.js // 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 // WAN follow-up and /voicediag's WAN check bucket. Same "fire the
// fetches in parallel + normalise into a stable shape + preserve // fetches in parallel + normalise into a stable shape + preserve
// per-metric failures in errors[]" pattern used by the Meraki // 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'; 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 * store's Prisma SD-WAN posture. Never throws always returns the
* shape documented at the top of this file. * 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 | | 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 | | 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). `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 COMM_SERVICES_COMPONENT = 'Communication Services';
export const CHECK_IDS = { export const CHECK_IDS = {
phonestatus: 'phonestatus', voicestatus: 'voicestatus',
callreport: 'callreport', callreport: 'callreport',
avstatus: 'avstatus', avstatus: 'avstatus',
}; };
@ -109,7 +109,7 @@ export function resolveEnrichmentPlan(ticket, classification) {
} }
const checks = []; const checks = [];
if (callQuality) { if (callQuality) {
checks.push(CHECK_IDS.phonestatus, CHECK_IDS.callreport); checks.push(CHECK_IDS.voicestatus, CHECK_IDS.callreport);
} else if (spam) { } else if (spam) {
checks.push(CHECK_IDS.callreport); checks.push(CHECK_IDS.callreport);
} }
@ -128,7 +128,7 @@ export function resolveEnrichmentPlan(ticket, classification) {
if (classification.kind === 'phone') { if (classification.kind === 'phone') {
return { return {
checks: [CHECK_IDS.phonestatus], checks: [CHECK_IDS.voicestatus],
matchedRules: ['default-phone'], matchedRules: ['default-phone'],
storeNum, storeNum,
skip: false, skip: false,

View file

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

View file

@ -27,7 +27,7 @@
// line — so future non-store enrichment tooling can pick it up later. // line — so future non-store enrichment tooling can pick it up later.
// //
// Comment format — Jira Cloud v3 requires ADF. We emit an italic // 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 // /avstatus chat command would produce, converted to ADF paragraphs
// via utils/markdownToAdf. This preserves clickable Meraki links, // via utils/markdownToAdf. This preserves clickable Meraki links,
// bold device names, and paragraph structure that the previous // 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. // 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). // Delegates to collectPhoneStatus (which already does Webex phones/DECT + Meraki attach + relevant ports).
// Adds optional Meraki topology for viz mirroring the AV dashboard. // 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 { collectPhoneStatus } from './phoneService.js';
import { getMerakiTopology } from '../integrations/meraki/devices.js'; import { getMerakiTopology } from '../integrations/meraki/devices.js';

View file

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

View file

@ -1,8 +1,18 @@
// services/renderers/mppPhoneDiagnosticsRenderer.js // 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 { formatDisplayTime } from '../../utils/time.js';
import {
shortenFirmware,
formatHeaderStatusLine,
headerIconForLine,
isLineRegistered,
formatRebootEntry,
formatProvisioningResync,
formatFirmwareUpgrade,
collectIssues,
} from './mppPhoneFormatters.js';
/** /**
* @param {Array} results probeAll() output * @param {Array} results probeAll() output
@ -40,136 +50,142 @@ function renderOnePhone(r, { verbose }) {
const ip = r.phone?.ip || r.parsed?.network?.ipv4 || '?'; const ip = r.phone?.ip || r.parsed?.network?.ipv4 || '?';
if (!r.ok) { if (!r.ok) {
return `⚠️ **${label}** (${ip}) — probe failed: ${r.error?.message || 'unknown error'}` + return `⚠️ **${label}** · \`${ip}\` — probe failed: ${r.error?.message || 'unknown error'}` +
(r.error?.hint ? `\n_${r.error.hint}_` : ''); (r.error?.hint ? `\n_${r.error.hint}_` : '');
} }
const mpp = r.mpp || {}; const mpp = r.mpp || {};
const parsed = mpp.parsed || r.parsed || {}; const parsed = mpp.parsed || r.parsed || {};
const verdict = mpp.verdict || r.verdict || {};
const device = parsed.device || {}; const device = parsed.device || {};
const network = parsed.network || {};
const phoneStatus = parsed.phoneStatus || {}; const phoneStatus = parsed.phoneStatus || {};
const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0]; const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0];
const neighbor = mpp.networkNeighbor || {}; const neighbor = mpp.networkNeighbor || {};
const download = mpp.download || {}; const download = mpp.download || {};
const system = mpp.system || {}; const issues = collectIssues(mpp, parsed);
const icon = verdict.healthy ? '✅' : '⚠️'; let out = renderPhoneHeader({ label, ip, device, phoneStatus, neighbor, line1 });
const fwShort = shortenFirmware(device.firmware); out += renderRegistrationSection(line1, phoneStatus);
let out = `${icon} **${label}** (${ip})\n`; out += renderSwitchSection(neighbor);
out += ` ${device.product || '?'} · ${device.mac || '?'} · ${fwShort}\n`; out += renderProvisioningSection(parsed, download);
out += renderIssuesSection(issues);
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`;
}
}
if (verbose) { if (verbose) {
out += renderVerboseTier(r, parsed, neighbor, mpp.probes); out += renderVerboseTier(r, parsed, neighbor, mpp.probes, line1);
} }
return out; 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 = ''; let out = '';
const otherLines = (parsed.lines || []).filter((l) => l.index !== 1); const otherLines = (parsed.lines || []).filter((l) => l.index !== 1);
if (otherLines.length > 0) {
out += '**Extensions**\n';
for (const line of otherLines) { for (const line of otherLines) {
out += ` Ext ${line.index}: ${line.registrationState || '?'}\n`; 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 || {}; const ps = parsed.phoneStatus || {};
if (ps.ipv6Status || ps.ipv6Address) { 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) { 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 counters = neighbor.errorCounters || {};
const counterKeys = Object.keys(counters); const counterKeys = Object.keys(counters);
if (counterKeys.length > 0) { if (counterKeys.length > 0) {
out += ' Ethernet errors:\n'; out += '**Ethernet errors**\n';
for (const k of counterKeys.slice(0, 8)) { 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 : []; const probeList = Array.isArray(probes) ? probes : [];
if (probeList.length > 0) { if (probeList.length > 0) {
out += ' Probe paths:\n'; out += '**Probe paths**\n```\nSTATUS BYTES PATH\n';
out += ' ```\n';
out += ' STATUS BYTES PATH\n';
for (const p of probeList) { for (const p of probeList) {
const status = p.status == null ? 'ERR' : String(p.status); 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`;
@ -179,26 +195,3 @@ function renderVerboseTier(r, parsed, neighbor, probes) {
return out; 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 // 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 // BOTH the chat reply (`bot.say('markdown', md)`) AND the Jira poller
// comment (via utils/markdownToAdf). Byte-for-byte identical to what // comment (via utils/markdownToAdf).
// the chat command used to emit for a given input.
// //
// Options // Options
// storeNum (required) header string uses it // 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 {string} opts.storeNum 2-6 digit store id (header text)
* @param {boolean} [opts.detailed=false] * @param {boolean} [opts.detailed=false]
* @param {boolean} [opts.footer=true] * @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. * @returns {string} markdown, whitespace-trimmed and ready to send.
*/ */
export function renderPhoneStatusMarkdown(data, opts = {}) { export function renderPhoneStatusMarkdown(data, opts = {}) {
@ -50,12 +35,9 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
storeNum, storeNum,
detailed = false, detailed = false,
footer = true, footer = true,
dectFollowUpBaseCount = 0,
mppFollowUpPhoneCount = 0,
wanFollowUpEnabled = false,
} = opts; } = opts;
let reply = `**Phone Status - Store ${storeNum}**\n\n`; let reply = `**Voice Status - Store ${storeNum}**\n\n`;
const phones = data.phones?.data || []; const phones = data.phones?.data || [];
const dectBasestations = data.dectBasestations || []; const dectBasestations = data.dectBasestations || [];
@ -141,11 +123,6 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
} }
reply += '\n'; 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 // DECT Basestations
@ -216,29 +193,12 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
}); });
reply += '\n'; reply += '\n';
} }
// DECT relay follow-up notice. Only shown when the caller has
// told us a follow-up is actually in-flight (chat handler, after
// discoverDectBases returned a non-empty list). Silent for HTTP /
// Jira surfaces where a follow-up doesn't happen.
if (dectFollowUpBaseCount > 0) {
const n = dectFollowUpBaseCount;
reply += `_⏳ Base-station diagnostics loading for ${n} base${n === 1 ? '' : 's'} — a follow-up message will arrive shortly._\n\n`;
}
} }
if (detailed) { if (detailed) {
reply += `_Detailed mode — additional fields above (use without ?detailed=true for compact view)_\n`; 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) { if (footer) {
reply += `\n*Last checked: ${formatDisplayTime()}*`; reply += `\n*Last checked: ${formatDisplayTime()}*`;
} }
@ -246,91 +206,3 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
return reply.trim(); 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 // 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 // Takes a `collectSdwanForStore(storeNum)` result and produces the
// section that shows healthscore + per-path LQM + active alarms. // 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 { wanAppRtpJitterCheck } from './wan/wanAppRtpJitter.js';
import { wanAlarmsCheck } from './wan/wanAlarms.js'; import { wanAlarmsCheck } from './wan/wanAlarms.js';
import { wanTunnelsCheck } from './wan/wanTunnels.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 // Order: user-facing feature signals first (things an operator can
// see from the phone UI), then LAN-side port hygiene, then the WAN // see from the phone UI), then LAN-side port hygiene, then the WAN
@ -92,6 +94,8 @@ export const CHECKS = [
wanAppRtpLossCheck, wanAppRtpLossCheck,
wanAppRtpJitterCheck, wanAppRtpJitterCheck,
wanAlarmsCheck, wanAlarmsCheck,
mppDeskPhoneRelayCheck,
dectBaseRelayCheck,
phoneOnlineCheck, 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 // Defaults match the ITU-T G.114 / RFC 3550 references for voice
// quality. Any of these can be overridden per-tenant via env; the // quality. Any of these can be overridden per-tenant via env; the
// renderer + the checks read from the same accessors so the icon // 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. // voicediag.
function num(envKey, fallback) { 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 phoneStatus = phoneStatusRes.status === 'fulfilled' ? phoneStatusRes.value : null;
const sdwanData = sdwanRes.status === 'fulfilled' ? sdwanRes.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; const personLabel = person?.displayName || email;
return { return {
@ -143,6 +176,8 @@ export async function buildContext(storeNum, opts = {}) {
// (or when the Prisma integration hasn't been configured). // (or when the Prisma integration hasn't been configured).
sdwanData, sdwanData,
sdwanSite: sdwanData?.site || null, 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', () => { test('downloadStatusWarnings: flags MIC cert failure', () => {
const parsed = parseDownloadStatusJson(FIXTURE); const parsed = parseDownloadStatusJson(FIXTURE);
const warnings = downloadStatusWarnings(parsed); 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); assert.equal(parsed.micCert.failed, true);
}); });

View file

@ -18,7 +18,7 @@ test('extractStoreFromSummary parses Store NNNN', () => {
assert.equal(extractStoreFromSummary('no store here'), null); 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( const plan = resolveEnrichmentPlan(
{ {
summary: 'Store 2477 - garbled voice on inbound calls', 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' }, { kind: 'phone', storeNum: '2477', reason: 'desk phone issue' },
); );
assert.equal(plan.skip, false); 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')); 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')); 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( const plan = resolveEnrichmentPlan(
{ {
summary: 'Store 100 - spam and garbled audio', 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' }, { 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', () => { 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' }, { 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']); assert.deepEqual(plan.matchedRules, ['default-phone']);
}); });
test('default phone kind runs phonestatus only', () => { test('default phone kind runs voicestatus only', () => {
const plan = resolveEnrichmentPlan( const plan = resolveEnrichmentPlan(
{ {
summary: 'Store 300 - phone not registering', summary: 'Store 300 - phone not registering',
@ -76,7 +76,7 @@ test('default phone kind runs phonestatus only', () => {
}, },
{ kind: 'phone', storeNum: '300', reason: 'registration' }, { 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']); assert.deepEqual(plan.matchedRules, ['default-phone']);
}); });

View file

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

View file

@ -5,6 +5,14 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { buildMppProbeView } from '../integrations/cisco-mpp-phone/aggregateProbe.js'; 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'; import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); 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 }); const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false });
assert.match(md, /\*\*MPP Desk Phone Diagnostics — Store 782\*\*/); 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, /Registered/);
assert.match(md, /SW00782R/); assert.match(md, /SW00782R/);
assert.match(md, /Port 45/); assert.match(md, /Port 45/);
assert.match(md, /cisco\.sipflash\.com/); assert.match(md, /Resync: cisco\.sipflash\.com · OK/);
assert.match(md, /MIC cert/i); assert.match(md, /MIC cert download failed — .*File not found/i);
assert.doesNotMatch(md, /Probe paths:/); 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 }); 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, /\/Status\.json/);
assert.match(md, /\*\*Extensions\*\*/);
assert.match(md, /Ext 2:/); 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', () => { test('renderMppPhoneDiagnosticsMarkdown: relay failure line', () => {
@ -69,5 +118,13 @@ test('buildMppProbeView: merges MIC warning into verdict', () => {
nsJson: NS, nsJson: NS,
}); });
assert.equal(mpp.verdict.healthy, false); 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 { import {
renderPhoneStatusMarkdown, renderPhoneStatusMarkdown,
renderDectDiagnosticsMarkdown,
} from '../services/renderers/phoneStatusRenderer.js'; } from '../services/renderers/phoneStatusRenderer.js';
import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js'; import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js';
import { renderDectStatusMarkdown } from '../services/renderers/dectStatusRenderer.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', () => { test('phone renderer: header and empty-store message', () => {
const md = renderPhoneStatusMarkdown({}, { storeNum: '782' }); 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/); 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) // Full /dectdiag dump (renderDectStatusMarkdown)
// ─────────────────────────────────────────────────────────────
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)
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
const fullOkResult = () => ({ 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. // Narrow, hand-rolled markdown → Atlassian Document Format converter.
// //
// Grammar supported (deliberately minimal, matches what our chat // 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): // commands/avStatus.js):
// //
// **text** → { type:'text', text, marks:[{type:'strong'}] } // **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;
}