Compare commits
2 commits
9d0dbb071e
...
7c7d0a898c
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c7d0a898c | |||
| 1117be40cc |
19 changed files with 1000 additions and 16 deletions
|
|
@ -11,6 +11,11 @@ SERVER_PORT=1800
|
||||||
# Logging level: info (default - clean), debug (verbose, includes per-fetch details)
|
# Logging level: info (default - clean), debug (verbose, includes per-fetch details)
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# IANA timezone for "Last checked" footers in chat output (avstatus,
|
||||||
|
# phonestatus, etc.). The bot often runs in UTC inside Docker; this
|
||||||
|
# keeps timestamps in operator-local time. Default: America/New_York.
|
||||||
|
# DISPLAY_TIMEZONE=America/New_York
|
||||||
|
|
||||||
# Verbose Webex framework debug logs. Default off; auto-enabled when LOG_LEVEL=debug.
|
# Verbose Webex framework debug logs. Default off; auto-enabled when LOG_LEVEL=debug.
|
||||||
# WEBEX_FRAMEWORK_DEBUG=false
|
# WEBEX_FRAMEWORK_DEBUG=false
|
||||||
|
|
||||||
|
|
|
||||||
418
commands/dectStatus.js
Normal file
418
commands/dectStatus.js
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
// src/commands/dectStatus.js
|
||||||
|
//
|
||||||
|
// /dectstatus <store> — full DECT basestation diagnostics via the
|
||||||
|
// on-prem relay agent. Complements the compact
|
||||||
|
// follow-up that /phonestatus already posts:
|
||||||
|
// this is the full status.xml dump (device,
|
||||||
|
// firmware, reboot log, network, RTP, security,
|
||||||
|
// emergency numbers) 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 { collectPhoneStatus } from '../services/phoneService.js';
|
||||||
|
import { discoverDectBases } from '../services/dectDiscovery.js';
|
||||||
|
import { collectAll, execAction } from '../services/dectCollectorService.js';
|
||||||
|
import { getDectRelayHub } from '../services/dectRelayHub.js';
|
||||||
|
import { renderDectStatusMarkdown } from '../services/renderers/dectStatusRenderer.js';
|
||||||
|
import { pendingDectActions } from '../utils/pendingDectActions.js';
|
||||||
|
import { extractRequester, describeRequester } from '../utils/requester.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
/** Mutating actions the chat cards expose. Must match agent capabilities. */
|
||||||
|
export const DECT_STATUS_ACTIONS = Object.freeze({
|
||||||
|
reboot: {
|
||||||
|
id: 'reboot',
|
||||||
|
label: 'Reboot',
|
||||||
|
emoji: '🔄',
|
||||||
|
severity: 'warn',
|
||||||
|
blurb: 'Graceful reboot — waits for active calls to end when possible.',
|
||||||
|
},
|
||||||
|
'force-reboot': {
|
||||||
|
id: 'force-reboot',
|
||||||
|
label: 'Force Reboot',
|
||||||
|
emoji: '⚡',
|
||||||
|
severity: 'warn',
|
||||||
|
blurb: 'Forced reboot — kills active calls within ~1 minute.',
|
||||||
|
},
|
||||||
|
'factory-reset': {
|
||||||
|
id: 'factory-reset',
|
||||||
|
label: 'Factory Reset',
|
||||||
|
emoji: '💣',
|
||||||
|
severity: 'danger',
|
||||||
|
blurb:
|
||||||
|
'Wipes the base to factory defaults. It must be re-onboarded from Control Hub afterward.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const REQUEST_ACTION = 'dect_status_request';
|
||||||
|
const CONFIRM_ACTION = 'dect_status_confirm';
|
||||||
|
const CANCEL_ACTION = 'dect_status_cancel';
|
||||||
|
|
||||||
|
/** Action ids handled by index.js attachmentAction dispatch. */
|
||||||
|
export const DECT_STATUS_CARD_ACTIONS = new Set([
|
||||||
|
REQUEST_ACTION,
|
||||||
|
CONFIRM_ACTION,
|
||||||
|
CANCEL_ACTION,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function handleDectStatus(bot, trigger) {
|
||||||
|
const query = trigger.query || {};
|
||||||
|
const args = trigger.args || [];
|
||||||
|
const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim();
|
||||||
|
|
||||||
|
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
'Please provide a 2–4 digit store number.\n' +
|
||||||
|
'Example: `/dectstatus 782`\n' +
|
||||||
|
'HTTP: `?storeNum=782`',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional single-base filter: second arg as IP or MAC.
|
||||||
|
const filterRaw = (args[1] || query.base || query.ip || query.mac || '').trim();
|
||||||
|
|
||||||
|
logger('dect:status', `Collecting DECT status for store ${storeNum}` +
|
||||||
|
(filterRaw ? ` (filter=${filterRaw})` : ''));
|
||||||
|
|
||||||
|
// Relay status snapshot for the header — cheap, no I/O.
|
||||||
|
let relayStatus = null;
|
||||||
|
try {
|
||||||
|
if (process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||||
|
relayStatus = getDectRelayHub().status();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('dect:status', `Relay status unavailable: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
'⚠️ DECT relay is not configured on this bot (`DECT_RELAY_AGENT_TOKEN` unset). ' +
|
||||||
|
'Set the token and run dect-relay-agent in the data center to enable `/dectstatus`.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let phoneData;
|
||||||
|
try {
|
||||||
|
phoneData = await collectPhoneStatus(storeNum);
|
||||||
|
} catch (err) {
|
||||||
|
logger('dect:status', `collectPhoneStatus failed for store ${storeNum}: ${err.message}`, 'error');
|
||||||
|
await bot.say('markdown', `❌ Failed to look up store ${storeNum}: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bases, warnings: discoveryWarnings } = discoverDectBases(phoneData || {});
|
||||||
|
let targets = bases;
|
||||||
|
if (filterRaw) {
|
||||||
|
targets = filterBases(bases, filterRaw);
|
||||||
|
if (targets.length === 0) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`No discovered base matched \`${filterRaw}\` for store ${storeNum}.\n` +
|
||||||
|
(bases.length
|
||||||
|
? `Known bases: ${bases.map((b) => `${b.name} (${b.ip})`).join(', ')}`
|
||||||
|
: '_No bases discovered at all._'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
targets.length
|
||||||
|
? `⏳ Collecting status from **${targets.length}** DECT base(s) at store ${storeNum}…`
|
||||||
|
: `Looking up DECT bases for store ${storeNum}…`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await collectAll(targets);
|
||||||
|
const md = renderDectStatusMarkdown(results, {
|
||||||
|
storeNum,
|
||||||
|
relay: relayStatus,
|
||||||
|
discoveryWarnings,
|
||||||
|
});
|
||||||
|
await bot.say('markdown', md || 'No data available.');
|
||||||
|
|
||||||
|
// Action cards are chat-only (need adaptive-card UX + room).
|
||||||
|
if (trigger.person) {
|
||||||
|
const okBases = results.filter((r) => r.ok && r.base?.ip);
|
||||||
|
for (const r of okBases) {
|
||||||
|
try {
|
||||||
|
const card = buildBaseActionCard({ storeNum, base: r.base });
|
||||||
|
await bot.say({
|
||||||
|
markdown: `Actions for **${r.base.name || r.base.ip}**:`,
|
||||||
|
attachments: [{
|
||||||
|
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||||
|
content: card,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger(
|
||||||
|
'dect:status',
|
||||||
|
`Failed to post action card for ${r.base?.ip}: ${err.message}`,
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaptive-card submit handler. Dispatched from index.js for
|
||||||
|
* dect_status_* action ids. Handles request → confirm → execute.
|
||||||
|
*/
|
||||||
|
export async function handleDectStatusAction(bot, trigger) {
|
||||||
|
const action = trigger.attachmentAction;
|
||||||
|
const inputs = action?.inputs || {};
|
||||||
|
const actionType = inputs.action;
|
||||||
|
const roomId = trigger.roomId || action?.roomId;
|
||||||
|
const requester = extractRequester(trigger);
|
||||||
|
|
||||||
|
if (actionType === REQUEST_ACTION) {
|
||||||
|
const dectAction = inputs.dectAction;
|
||||||
|
const meta = DECT_STATUS_ACTIONS[dectAction];
|
||||||
|
if (!meta) {
|
||||||
|
logger('dect:status:action', `Unknown dectAction "${dectAction}" — ignoring`, 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const storeNum = String(inputs.storeNum || '').trim();
|
||||||
|
const baseIp = String(inputs.baseIp || '').trim();
|
||||||
|
const baseMac = String(inputs.baseMac || '').trim();
|
||||||
|
const baseName = String(inputs.baseName || baseIp || 'base').trim();
|
||||||
|
if (!storeNum || !baseIp) {
|
||||||
|
logger('dect:status:action', 'request missing storeNum/baseIp — ignoring', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardId = randomUUID();
|
||||||
|
pendingDectActions.set(cardId, {
|
||||||
|
storeNum,
|
||||||
|
baseIp,
|
||||||
|
baseMac,
|
||||||
|
baseName,
|
||||||
|
dectAction,
|
||||||
|
requester,
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmCard = buildConfirmCard({
|
||||||
|
cardId,
|
||||||
|
storeNum,
|
||||||
|
baseName,
|
||||||
|
baseIp,
|
||||||
|
meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bot.say({
|
||||||
|
markdown:
|
||||||
|
`${meta.emoji} Confirm **${meta.label}** on **${baseName}** (${baseIp}) at store ${storeNum}?`,
|
||||||
|
attachments: [{
|
||||||
|
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||||
|
content: confirmCard,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
logger(
|
||||||
|
'dect:audit',
|
||||||
|
`REQUEST ${dectAction} store=${storeNum} base=${baseIp} by ${describeRequester(requester)} card=${cardId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType === CONFIRM_ACTION || actionType === CANCEL_ACTION) {
|
||||||
|
const { cardId } = inputs;
|
||||||
|
if (!cardId) {
|
||||||
|
logger('dect:status:action', `Missing cardId on ${actionType} — ignoring`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pendingDectActions.has(cardId)) {
|
||||||
|
logger('dect:status:action', `Card ${cardId} is expired or unknown`);
|
||||||
|
await bot.say('markdown', '⚠️ That action card has expired. Re-run `/dectstatus` and try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = pendingDectActions.get(cardId);
|
||||||
|
pendingDectActions.delete(cardId); // one-shot
|
||||||
|
|
||||||
|
if (actionType === CANCEL_ACTION) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`❌ **${DECT_STATUS_ACTIONS[data.dectAction]?.label || data.dectAction}** cancelled ` +
|
||||||
|
`for **${data.baseName}** (${data.baseIp}). No changes made.`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'dect:audit',
|
||||||
|
`CANCELLED ${data.dectAction} store=${data.storeNum} base=${data.baseIp} by ${describeRequester(requester)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONFIRM
|
||||||
|
const meta = DECT_STATUS_ACTIONS[data.dectAction] || {
|
||||||
|
id: data.dectAction,
|
||||||
|
label: data.dectAction,
|
||||||
|
emoji: '🔧',
|
||||||
|
};
|
||||||
|
logger(
|
||||||
|
'dect:audit',
|
||||||
|
`CONFIRMED ${data.dectAction} store=${data.storeNum} base=${data.baseIp} by ${describeRequester(requester)}`,
|
||||||
|
);
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`${meta.emoji} Running **${meta.label}** on **${data.baseName}** (${data.baseIp})…`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await execAction(
|
||||||
|
{ ip: data.baseIp, mac: data.baseMac, name: data.baseName },
|
||||||
|
data.dectAction,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`✅ **${meta.label}** issued on **${data.baseName}** (${data.baseIp})` +
|
||||||
|
(result.elapsedMs != null ? ` in ${result.elapsedMs}ms` : '') +
|
||||||
|
`.\n_Re-run \`/dectstatus ${data.storeNum}\` in a minute to confirm the base is back._`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'dect:audit',
|
||||||
|
`COMPLETED ${data.dectAction} store=${data.storeNum} base=${data.baseIp} elapsed=${result.elapsedMs}ms`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`❌ **${meta.label}** failed on **${data.baseName}** (${data.baseIp}): ` +
|
||||||
|
`${result.error?.message || 'unknown error'}` +
|
||||||
|
(result.error?.hint ? `\n_${result.error.hint}_` : ''),
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'dect:audit',
|
||||||
|
`FAILED ${data.dectAction} store=${data.storeNum} base=${data.baseIp}: ${result.error?.message}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('dect:status:action', `Ignoring unhandled action type ${actionType}`, 'debug');
|
||||||
|
void roomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cards ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildBaseActionCard({ storeNum, base }) {
|
||||||
|
const actions = Object.values(DECT_STATUS_ACTIONS).map((meta) => ({
|
||||||
|
type: 'Action.Submit',
|
||||||
|
title: `${meta.emoji} ${meta.label}`,
|
||||||
|
data: {
|
||||||
|
action: REQUEST_ACTION,
|
||||||
|
dectAction: meta.id,
|
||||||
|
storeNum: String(storeNum),
|
||||||
|
baseIp: base.ip,
|
||||||
|
baseMac: base.mac || '',
|
||||||
|
baseName: base.name || base.ip,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'AdaptiveCard',
|
||||||
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||||
|
version: '1.3',
|
||||||
|
body: [
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
size: 'Medium',
|
||||||
|
weight: 'Bolder',
|
||||||
|
text: `${base.name || 'Basestation'} (${base.ip})`,
|
||||||
|
wrap: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `MAC \`${base.mac || '?'}\` · store ${storeNum}. Pick an action — you'll confirm before anything runs.`,
|
||||||
|
wrap: true,
|
||||||
|
spacing: 'Small',
|
||||||
|
isSubtle: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfirmCard({ cardId, storeNum, baseName, baseIp, meta }) {
|
||||||
|
const dangerNote = meta.severity === 'danger'
|
||||||
|
? ' **This is destructive and cannot be undone.**'
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
type: 'AdaptiveCard',
|
||||||
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||||
|
version: '1.3',
|
||||||
|
body: [
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
size: 'Medium',
|
||||||
|
weight: 'Bolder',
|
||||||
|
text: `${meta.emoji} Confirm ${meta.label}?`,
|
||||||
|
wrap: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `**${baseName}** (${baseIp}) at store ${storeNum}.${dangerNote}`,
|
||||||
|
wrap: true,
|
||||||
|
spacing: 'Small',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: meta.blurb,
|
||||||
|
wrap: true,
|
||||||
|
spacing: 'Small',
|
||||||
|
isSubtle: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: 'Action.Submit',
|
||||||
|
title: `✅ ${meta.label}`,
|
||||||
|
style: meta.severity === 'danger' ? 'destructive' : 'positive',
|
||||||
|
data: { action: CONFIRM_ACTION, cardId },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Action.Submit',
|
||||||
|
title: '❌ Cancel',
|
||||||
|
data: { action: CANCEL_ACTION, cardId },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function filterBases(bases, raw) {
|
||||||
|
const needle = String(raw).trim().toLowerCase();
|
||||||
|
const needleMac = needle.replace(/[^0-9a-f]/g, '');
|
||||||
|
return bases.filter((b) => {
|
||||||
|
if (b.ip && b.ip.toLowerCase() === needle) return true;
|
||||||
|
if (b.mac) {
|
||||||
|
const macHex = b.mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
|
||||||
|
if (macHex === needleMac && needleMac.length === 12) return true;
|
||||||
|
if (b.mac.toLowerCase() === needle) return true;
|
||||||
|
}
|
||||||
|
if (b.name && b.name.toLowerCase().includes(needle)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ 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)',
|
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
|
||||||
|
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
|
||||||
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
|
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
|
||||||
|
|
||||||
// Jira
|
// Jira
|
||||||
|
|
@ -59,10 +60,32 @@ const LONG_HELP = {
|
||||||
'Detailed mode adds firmware, serial, SIP details and errors.',
|
'Detailed mode adds firmware, serial, SIP details and errors.',
|
||||||
'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).',
|
'When the store is Prisma SD-WAN managed (site name `CG<store>` padded to 5 digits), a follow-up **WAN Diagnostics** message arrives with per-path latency/jitter/loss/MOS, site healthscore, and any alarms — averaged over the last 7 days by default (widened from 24h so sporadic Webex Calling stores get enough call samples; override via `WAN_STANDARD_WINDOW_MINUTES` or pass `--window 24h` on /voicediag).',
|
||||||
'If `PRISMA_APP_ID_VOICE` is configured (e.g. pointing at `Webex_Calling_RTP` for Webex Calling shops), the follow-up also includes a **Voice Traffic Quality** section with real DPI-measured MOS / packet loss / jitter for that app — surfaces transient degradation the 7d link-probe averages smooth away.',
|
'If `PRISMA_APP_ID_VOICE` is configured (e.g. pointing at `Webex_Calling_RTP` for Webex Calling shops), the follow-up also includes a **Voice Traffic Quality** section with real DPI-measured MOS / packet loss / jitter for that app — surfaces transient degradation the 7d link-probe averages smooth away.',
|
||||||
|
'For the full DECT base dump + reboot/factory-reset controls, use `/dectstatus <store>`.',
|
||||||
'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: {
|
||||||
|
title: '/dectstatus',
|
||||||
|
usage: [
|
||||||
|
'/dectstatus <store>',
|
||||||
|
'/dectstatus <store> <ip|mac>',
|
||||||
|
],
|
||||||
|
examples: [
|
||||||
|
'/dectstatus 782',
|
||||||
|
'/dectstatus 782 10.12.34.56',
|
||||||
|
'/dectstatus 782 6c:ab:05:12:34:56',
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
'Aliases: `/dect`.',
|
||||||
|
'Pulls the full `status.xml` dump from every reachable DBS-210 base at the store via the on-prem DECT relay agent (device, firmware, reboot log, network stats, RTP, security, emergency numbers, health verdict).',
|
||||||
|
'Optional second arg filters to one base by IP, MAC, or name substring.',
|
||||||
|
'Chat-only action cards per reachable base: **Reboot**, **Force Reboot**, **Factory Reset**. Each requires a confirm click; outcomes are audited under `dect:audit`.',
|
||||||
|
'Requires `DECT_RELAY_AGENT_TOKEN` on the bot and a live `dect-relay-agent` in the data center. When the relay is offline the command still reports discovery results and the offline state.',
|
||||||
|
'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: {
|
||||||
title: '/voicediag',
|
title: '/voicediag',
|
||||||
usage: [
|
usage: [
|
||||||
|
|
@ -230,7 +253,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', 'voicediag'] },
|
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag'] },
|
||||||
{ 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'] },
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import { summarizeJiraTicket } from '../services/jiraSummarizer.js';
|
import { summarizeJiraTicket } from '../services/jiraSummarizer.js';
|
||||||
import { analyzeCommonIssues } from '../services/jiraSummarizer.js';
|
import { analyzeCommonIssues } from '../services/jiraSummarizer.js';
|
||||||
import jira from '../integrations/jira/JiraClient.js';
|
import jira from '../integrations/jira/JiraClient.js';
|
||||||
|
import { formatDisplayTime } from '../utils/time.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
const MAX_TICKETS = 20;
|
const MAX_TICKETS = 20;
|
||||||
|
|
@ -134,7 +135,7 @@ export async function handleJiraHistory(bot, trigger) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`;
|
reply += `\n*Last checked: ${formatDisplayTime()}*`;
|
||||||
await bot.say('markdown', reply.trim());
|
await bot.say('markdown', reply.trim());
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import jira from '../integrations/jira/JiraClient.js';
|
||||||
import { summarizeJiraTicket } from '../services/jiraSummarizer.js';
|
import { summarizeJiraTicket } from '../services/jiraSummarizer.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { getStatusEmoji, calculateDaysOpen } from '../services/jiraService.js';
|
import { getStatusEmoji, calculateDaysOpen } from '../services/jiraService.js';
|
||||||
|
import { formatDisplayTime } from '../utils/time.js';
|
||||||
|
|
||||||
export async function handleJiraTicket(bot, trigger) {
|
export async function handleJiraTicket(bot, trigger) {
|
||||||
logger('jira:ticket', 'Handler entered', 'debug');
|
logger('jira:ticket', 'Handler entered', 'debug');
|
||||||
|
|
@ -44,7 +45,7 @@ export async function handleJiraTicket(bot, trigger) {
|
||||||
reply += `${aiSummary}\n\n`;
|
reply += `${aiSummary}\n\n`;
|
||||||
reply += `${statusEmoji} **Status:** ${fields.status?.name || '—'} • Component: ${component}\n`;
|
reply += `${statusEmoji} **Status:** ${fields.status?.name || '—'} • Component: ${component}\n`;
|
||||||
reply += `Assigned: ${assignee} • Open for: ${days} days\n\n`;
|
reply += `Assigned: ${assignee} • Open for: ${days} days\n\n`;
|
||||||
reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`;
|
reply += `\n*Last checked: ${formatDisplayTime()}*`;
|
||||||
|
|
||||||
await bot.say('markdown', reply.trim());
|
await bot.say('markdown', reply.trim());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
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 { handlePhoneStatus } from './phoneStatus.js';
|
||||||
|
import { handleDectStatus } from './dectStatus.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';
|
||||||
|
|
@ -44,6 +45,10 @@ export const commands = [
|
||||||
|
|
||||||
{ name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false },
|
{ name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false },
|
||||||
{ name: 'phonestatus', handler: handlePhoneStatus, mutating: false },
|
{ name: 'phonestatus', handler: handlePhoneStatus, mutating: false },
|
||||||
|
// /dectstatus — full DECT base dump via the on-prem relay + chat-only
|
||||||
|
// reboot/factory-reset cards. Read path is non-mutating; card submits
|
||||||
|
// only fire over Webex (see commands/dectStatus.js).
|
||||||
|
{ name: 'dectstatus', aliases: ['dect'], handler: handleDectStatus, mutating: false },
|
||||||
// /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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// src/commands/woHistory.js
|
// src/commands/woHistory.js
|
||||||
import { collectWoHistory } from '../services/woService.js';
|
import { collectWoHistory } from '../services/woService.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { formatDisplayTime } from '../utils/time.js';
|
||||||
|
|
||||||
export async function handleWoHistory(bot, trigger) {
|
export async function handleWoHistory(bot, trigger) {
|
||||||
logger('wo:history', 'Handler entered');
|
logger('wo:history', 'Handler entered');
|
||||||
|
|
@ -52,7 +53,7 @@ export async function handleWoHistory(bot, trigger) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`;
|
reply += `\n*Last checked: ${formatDisplayTime()}*`;
|
||||||
|
|
||||||
await bot.say('markdown', reply.trim() || 'No data available.');
|
await bot.say('markdown', reply.trim() || 'No data available.');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// src/commands/woSummary.js
|
// src/commands/woSummary.js
|
||||||
import { collectWoSummary } from '../services/woService.js';
|
import { collectWoSummary } from '../services/woService.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { formatDisplayTime } from '../utils/time.js';
|
||||||
|
|
||||||
export async function handleWoSummary(bot, trigger) {
|
export async function handleWoSummary(bot, trigger) {
|
||||||
logger('wo:summary', 'Handler entered');
|
logger('wo:summary', 'Handler entered');
|
||||||
|
|
@ -29,7 +30,7 @@ export async function handleWoSummary(bot, trigger) {
|
||||||
|
|
||||||
let reply = `**Work Order Summary – ${woNumber}**\n\n`;
|
let reply = `**Work Order Summary – ${woNumber}**\n\n`;
|
||||||
reply += `${summary || 'No summary data available.'}\n`;
|
reply += `${summary || 'No summary data available.'}\n`;
|
||||||
reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`;
|
reply += `\n*Last checked: ${formatDisplayTime()}*`;
|
||||||
|
|
||||||
await bot.say('markdown', reply.trim());
|
await bot.say('markdown', reply.trim());
|
||||||
|
|
||||||
|
|
|
||||||
127
index.js
127
index.js
|
|
@ -20,6 +20,10 @@ import { requireApiToken, requireAuthForAllCommands } from './utils/httpAuth.js'
|
||||||
// command fallback.
|
// command fallback.
|
||||||
import { handleUnknown } from './commands/unknownCommand.js';
|
import { handleUnknown } from './commands/unknownCommand.js';
|
||||||
import { handleDectProvisionAction } from './commands/provisionDect.js';
|
import { handleDectProvisionAction } from './commands/provisionDect.js';
|
||||||
|
import {
|
||||||
|
handleDectStatusAction,
|
||||||
|
DECT_STATUS_CARD_ACTIONS,
|
||||||
|
} from './commands/dectStatus.js';
|
||||||
import {
|
import {
|
||||||
applyOffboardConfirmation,
|
applyOffboardConfirmation,
|
||||||
cancelOffboardCard,
|
cancelOffboardCard,
|
||||||
|
|
@ -364,6 +368,23 @@ framework.on('attachmentAction', async (bot, trigger) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── DECT status (reboot / factory-reset confirm cards) ──
|
||||||
|
if (DECT_STATUS_CARD_ACTIONS.has(actionType)) {
|
||||||
|
try {
|
||||||
|
// Censor the card that was clicked so double-clicks can't re-fire.
|
||||||
|
// Request cards don't use the pending map until confirm is posted,
|
||||||
|
// so we always censor here for request + confirm/cancel alike.
|
||||||
|
await censorActionCard(bot, trigger, 'dect:status:action');
|
||||||
|
await handleDectStatusAction(bot, trigger);
|
||||||
|
} catch (err) {
|
||||||
|
logger('dect:status', `Dect status action error: ${err.message}`, 'error');
|
||||||
|
try {
|
||||||
|
await bot.say('markdown', `⚠️ Error handling DECT action: ${err.message}`);
|
||||||
|
} catch { /* ignore secondary say failure */ }
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Offboard confirm / cancel ──
|
// ── Offboard confirm / cancel ──
|
||||||
if (OFFBOARD_ACTIONS.has(actionType)) {
|
if (OFFBOARD_ACTIONS.has(actionType)) {
|
||||||
const { cardId } = action.inputs;
|
const { cardId } = action.inputs;
|
||||||
|
|
@ -568,8 +589,102 @@ framework.on('attachmentAction', async (bot, trigger) => {
|
||||||
|
|
||||||
framework.on("initialized", () => {
|
framework.on("initialized", () => {
|
||||||
logger('framework', 'Webex Framework is all fired up! [Press CTRL-C to quit]');
|
logger('framework', 'Webex Framework is all fired up! [Press CTRL-C to quit]');
|
||||||
|
// Patch @webex/plugin-messages so a 404 on Hydra messages.get during
|
||||||
|
// mercury event enrichment cannot take down the process. See
|
||||||
|
// hardenWebexMessagesPlugin() below.
|
||||||
|
try {
|
||||||
|
hardenWebexMessagesPlugin(framework.webex);
|
||||||
|
} catch (err) {
|
||||||
|
logger('webex:sdk', `Failed to harden messages plugin: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Webex SDK hardening
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// @webex/plugin-messages does this for every mercury activity:
|
||||||
|
//
|
||||||
|
// this.getMessageEvent(activity, type).then(this.fire(type));
|
||||||
|
//
|
||||||
|
// with NO .catch(). getMessageEvent() calls Hydra messages.get(). When that
|
||||||
|
// returns 404 (message already deleted, room the bot left, or an
|
||||||
|
// eventual-consistency race after we post a summary via BotClient REST —
|
||||||
|
// exactly the path the hourly Jira poller takes), the rejection becomes an
|
||||||
|
// unhandledRejection and our process-level handler would shut the bot down.
|
||||||
|
//
|
||||||
|
// Reproduce from logs (2026-07-10 11:00):
|
||||||
|
// [webex:bot] Sent markdown message to room aa9c1e50...
|
||||||
|
// [uncaught] Unhandled promise rejection: Unable to get message. (NotFound)
|
||||||
|
// [shutdown] Shutting down — unhandledRejection
|
||||||
|
//
|
||||||
|
// We re-bind onWebexApiEvent with an equivalent verb→type map and a .catch()
|
||||||
|
// so enrichment failures are logged and dropped. The unhandledRejection
|
||||||
|
// handler below also treats these as non-fatal as a belt-and-suspenders
|
||||||
|
// guard for other plugins with the same pattern.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} reason
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isBenignWebexSdkRejection(reason) {
|
||||||
|
if (!reason) return false;
|
||||||
|
const name = reason.name || '';
|
||||||
|
const message = (reason.message != null ? String(reason.message) : String(reason));
|
||||||
|
const status = reason.statusCode ?? reason.body?.statusCode ?? reason.status;
|
||||||
|
if (/unable to get message/i.test(message)) return true;
|
||||||
|
if ((name === 'NotFound' || status === 404) && /not found|unable to get/i.test(message)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the floating-promise onWebexApiEvent on the messages plugin.
|
||||||
|
* @param {object|null|undefined} webex
|
||||||
|
*/
|
||||||
|
function hardenWebexMessagesPlugin(webex) {
|
||||||
|
const plugin = webex?.messages;
|
||||||
|
if (!plugin || plugin.__collabfinderHardened) return;
|
||||||
|
if (
|
||||||
|
typeof plugin.onWebexApiEvent !== 'function' ||
|
||||||
|
typeof plugin.getMessageEvent !== 'function' ||
|
||||||
|
typeof plugin.fire !== 'function'
|
||||||
|
) {
|
||||||
|
logger('webex:sdk', 'messages plugin missing expected methods — skip harden', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same mapping as @webex/plugin-messages verbToType (share/post → created,
|
||||||
|
// delete → deleted). Keep in sync if the SDK adds verbs.
|
||||||
|
const verbToType = {
|
||||||
|
share: 'created',
|
||||||
|
post: 'created',
|
||||||
|
delete: 'deleted',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageEvent = plugin.getMessageEvent.bind(plugin);
|
||||||
|
const fire = plugin.fire.bind(plugin);
|
||||||
|
|
||||||
|
plugin.onWebexApiEvent = function collabfinderOnWebexApiEvent(event) {
|
||||||
|
const activity = event?.data?.activity;
|
||||||
|
if (!activity) return;
|
||||||
|
const type = verbToType[activity.verb];
|
||||||
|
if (!type) return;
|
||||||
|
getMessageEvent(activity, type)
|
||||||
|
.then(fire(type))
|
||||||
|
.catch((err) => {
|
||||||
|
logger(
|
||||||
|
'webex:sdk',
|
||||||
|
`Dropped message:${type} event (Hydra enrichment failed): ${err.message}`,
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
plugin.__collabfinderHardened = true;
|
||||||
|
logger('webex:sdk', 'Hardened messages plugin event handler (404-safe)');
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Command Handler (registry-driven)
|
// Command Handler (registry-driven)
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|
@ -652,7 +767,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 + future /dectstatus command',
|
'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',
|
||||||
'warn',
|
'warn',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -778,6 +893,16 @@ process.on('uncaughtException', (err) => {
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
process.on('unhandledRejection', (reason) => {
|
||||||
const msg = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
|
const msg = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
|
||||||
|
// Webex SDK / framework noise — log and keep running. A true app bug
|
||||||
|
// still takes us down below.
|
||||||
|
if (isBenignWebexSdkRejection(reason)) {
|
||||||
|
logger(
|
||||||
|
'uncaught',
|
||||||
|
`Ignoring benign Webex SDK rejection (bot stays up): ${reason?.message || reason}`,
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger('uncaught', `Unhandled promise rejection: ${msg}`, 'error');
|
logger('uncaught', `Unhandled promise rejection: ${msg}`, 'error');
|
||||||
shutdown('unhandledRejection', 1);
|
shutdown('unhandledRejection', 1);
|
||||||
});
|
});
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
// handler — same wired-vs-wireless branch, same double-arrow indent
|
// handler — same wired-vs-wireless branch, same double-arrow indent
|
||||||
// convention, same fallback for `{client:...}` vs flat shapes.
|
// convention, same fallback for `{client:...}` vs flat shapes.
|
||||||
|
|
||||||
import { simpleTimeAgo } from '../../utils/time.js';
|
import { simpleTimeAgo, formatDisplayTime } from '../../utils/time.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render an AV device-status markdown snapshot from a
|
* Render an AV device-status markdown snapshot from a
|
||||||
|
|
@ -250,7 +250,7 @@ export function renderAvStatusMarkdown(data, opts = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (footer) {
|
if (footer) {
|
||||||
reply += `*Last checked: ${new Date().toLocaleTimeString()}*`;
|
reply += `*Last checked: ${formatDisplayTime()}*`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.trim();
|
return reply.trim();
|
||||||
|
|
|
||||||
198
services/renderers/dectStatusRenderer.js
Normal file
198
services/renderers/dectStatusRenderer.js
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
// src/services/renderers/dectStatusRenderer.js
|
||||||
|
//
|
||||||
|
// Full CLI-style DECT base dump for `/dectstatus`. Complements the
|
||||||
|
// compact follow-up from renderDectDiagnosticsMarkdown (phonestatus):
|
||||||
|
// that one is "exceptions only"; this one is the complete status.xml
|
||||||
|
// picture (device, firmware, reboot log, network, RTP, security,
|
||||||
|
// emergency numbers, health verdict).
|
||||||
|
//
|
||||||
|
// Input is the same collectAll() result array used by the compact
|
||||||
|
// renderer. Pure — no I/O, no env.
|
||||||
|
|
||||||
|
import { formatDisplayTime } from '../../utils/time.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array} results collectAll() output
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.storeNum
|
||||||
|
* @param {boolean} [opts.footer=true]
|
||||||
|
* @param {object} [opts.relay] optional hub.status() snapshot
|
||||||
|
* @param {Array} [opts.discoveryWarnings]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function renderDectStatusMarkdown(results, opts = {}) {
|
||||||
|
const {
|
||||||
|
storeNum,
|
||||||
|
footer = true,
|
||||||
|
relay = null,
|
||||||
|
discoveryWarnings = [],
|
||||||
|
} = opts;
|
||||||
|
const list = Array.isArray(results) ? results : [];
|
||||||
|
|
||||||
|
const lines = [`**DECT Status — Store ${storeNum}**`, ''];
|
||||||
|
|
||||||
|
if (relay) {
|
||||||
|
if (relay.connected) {
|
||||||
|
const agent = relay.agent?.hostname || relay.agent?.version || 'connected';
|
||||||
|
lines.push(`_Relay: online (${agent})_`);
|
||||||
|
} else {
|
||||||
|
lines.push('_Relay: **offline** — base collect will fail until dect-relay-agent reconnects_');
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
lines.push('_No reachable DECT basestations discovered for this store._');
|
||||||
|
if (discoveryWarnings.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('**Discovery notes:**');
|
||||||
|
for (const w of discoveryWarnings) {
|
||||||
|
const who = w.mac || w.ip || 'base';
|
||||||
|
lines.push(`- ${who}: ${w.reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (footer) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`_Pulled at ${formatDisplayTime()}._`);
|
||||||
|
}
|
||||||
|
return lines.join('\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
if (i > 0) lines.push('', '---', '');
|
||||||
|
lines.push(...renderOneBaseFull(list[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discoveryWarnings.length > 0) {
|
||||||
|
lines.push('', '**Discovery notes (skipped bases):**');
|
||||||
|
for (const w of discoveryWarnings) {
|
||||||
|
const who = w.mac || w.ip || 'base';
|
||||||
|
lines.push(`- ${who}: ${w.reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footer) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(
|
||||||
|
`_Full base dump at ${formatDisplayTime()} via the DECT relay. ` +
|
||||||
|
`Use the action cards below for reboot / factory-reset (chat only)._`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOneBaseFull(r) {
|
||||||
|
const label = r.base?.name || `Basestation ${r.base?.mac || '?'}`;
|
||||||
|
const ip = r.base?.ip || '?';
|
||||||
|
const mac = r.base?.mac || '?';
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
lines.push(`⚠️ **${label}**`);
|
||||||
|
lines.push(`- IP: \`${ip}\` · MAC: \`${mac}\``);
|
||||||
|
lines.push(`- Collect failed: ${r.error?.message || 'unknown error'}`);
|
||||||
|
if (r.error?.hint) lines.push(`- _${r.error.hint}_`);
|
||||||
|
if (r.elapsedMs != null) lines.push(`- Elapsed: ${r.elapsedMs}ms`);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = r.data || {};
|
||||||
|
const verdict = r.verdict || {};
|
||||||
|
const icon = verdict.healthy ? '✅' : '⚠️';
|
||||||
|
lines.push(`${icon} **${label}**`);
|
||||||
|
|
||||||
|
// ── Device ──
|
||||||
|
lines.push('', '**Device**');
|
||||||
|
row(lines, 'Model', p.device?.model);
|
||||||
|
row(lines, 'System type', p.device?.systemType);
|
||||||
|
row(lines, 'Unit', [p.device?.unitName, p.device?.unitIndex].filter(Boolean).join(' · ') || null);
|
||||||
|
row(lines, 'MAC', p.device?.macAddress || mac);
|
||||||
|
row(lines, 'IP', p.device?.ipAddress || ip);
|
||||||
|
row(lines, 'RFPI', p.device?.rfpiAddress);
|
||||||
|
row(lines, 'RF band', p.device?.rfBand);
|
||||||
|
row(lines, 'Multi-cell', p.multiCell?.role || p.multiCell?.raw);
|
||||||
|
row(lines, 'Base status', p.baseStatus);
|
||||||
|
row(lines, 'Conflict', p.conflictInfo);
|
||||||
|
|
||||||
|
// ── Firmware ──
|
||||||
|
lines.push('', '**Firmware**');
|
||||||
|
row(lines, 'Version', p.firmware?.version);
|
||||||
|
row(lines, 'Update server', p.firmware?.updateServer);
|
||||||
|
row(lines, 'Update path', p.firmware?.updatePath);
|
||||||
|
|
||||||
|
// ── Time ──
|
||||||
|
lines.push('', '**Time / uptime**');
|
||||||
|
row(lines, 'Local time', p.time?.currentLocalTime);
|
||||||
|
row(lines, 'Uptime', p.time?.operatingTime);
|
||||||
|
if (r.elapsedMs != null) row(lines, 'Collect latency', `${r.elapsedMs}ms`);
|
||||||
|
|
||||||
|
// ── Reboot log ──
|
||||||
|
lines.push('', '**Reboot log** (newest first)');
|
||||||
|
const log = Array.isArray(p.rebootLog) ? p.rebootLog : [];
|
||||||
|
if (log.length === 0) {
|
||||||
|
lines.push('- _(none)_');
|
||||||
|
} else {
|
||||||
|
for (const entry of log) {
|
||||||
|
if (entry.unrecognized) {
|
||||||
|
lines.push(`- ??? ${entry.raw || ''}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const tag = entry.reasonCode === 80 ? '⚡' : '•';
|
||||||
|
lines.push(
|
||||||
|
`- ${tag} #${entry.sequence} ${entry.at} **${entry.reasonName}** (${entry.reasonCode}) fw=${entry.firmwareAtBoot || '?'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Network ──
|
||||||
|
lines.push('', '**Network stats** (since boot)');
|
||||||
|
row(lines, 'Tx packets', p.network?.txPackets);
|
||||||
|
row(lines, 'Tx dropped', p.network?.txDropped);
|
||||||
|
row(lines, 'Tx errors', p.network?.txErrors);
|
||||||
|
row(lines, 'Rx packets', p.network?.rxPackets);
|
||||||
|
row(lines, 'Rx dropped', p.network?.rxDropped);
|
||||||
|
row(lines, 'Rx errors', p.network?.rxErrors);
|
||||||
|
row(lines, 'Rx broadcasts', p.network?.rxBroadcasts);
|
||||||
|
|
||||||
|
// ── RTP ──
|
||||||
|
lines.push('', '**RTP**');
|
||||||
|
row(lines, 'Total since boot', p.rtp?.total);
|
||||||
|
row(lines, 'Current active', p.rtp?.current);
|
||||||
|
row(lines, 'Current local', p.rtp?.currentLocal);
|
||||||
|
row(lines, 'Current relay', p.rtp?.currentRelay);
|
||||||
|
|
||||||
|
// ── Security ──
|
||||||
|
lines.push('', '**Security**');
|
||||||
|
row(
|
||||||
|
lines,
|
||||||
|
'Custom CA',
|
||||||
|
p.security?.customCa?.installed
|
||||||
|
? (p.security.customCa.info || 'installed')
|
||||||
|
: 'Not installed',
|
||||||
|
);
|
||||||
|
row(lines, '802.1X protocol', p.security?.dot1x?.protocol);
|
||||||
|
row(lines, '802.1X status', p.security?.dot1x?.transactionStatus);
|
||||||
|
|
||||||
|
// ── Emergency ──
|
||||||
|
const emerg = Array.isArray(p.emergencyNumbers) ? p.emergencyNumbers : [];
|
||||||
|
lines.push('', '**Emergency numbers**');
|
||||||
|
lines.push(emerg.length ? `- ${emerg.join(', ')}` : '- _(none configured)_');
|
||||||
|
|
||||||
|
// ── Verdict ──
|
||||||
|
lines.push('', '**Health verdict**');
|
||||||
|
lines.push(`- healthy: **${verdict.healthy ? 'YES' : 'NO'}**`);
|
||||||
|
for (const w of verdict.warnings || []) {
|
||||||
|
lines.push(`- ⚠️ ${w}`);
|
||||||
|
}
|
||||||
|
for (const i of verdict.info || []) {
|
||||||
|
lines.push(`- ℹ️ ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(lines, label, value) {
|
||||||
|
if (value == null || value === '') return;
|
||||||
|
lines.push(`- **${label}:** ${value}`);
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
// `simpleTimeAgo` — the same helper the chat handler used, so relative
|
// `simpleTimeAgo` — the same helper the chat handler used, so relative
|
||||||
// times ("2h ago") stay consistent across chat and Jira surfaces.
|
// times ("2h ago") stay consistent across chat and Jira surfaces.
|
||||||
|
|
||||||
import { simpleTimeAgo, formatBytes } from '../../utils/time.js';
|
import { simpleTimeAgo, formatBytes, formatDisplayTime } from '../../utils/time.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a phone-status markdown snapshot from a `collectPhoneStatus`
|
* Render a phone-status markdown snapshot from a `collectPhoneStatus`
|
||||||
|
|
@ -232,7 +232,7 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (footer) {
|
if (footer) {
|
||||||
reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`;
|
reply += `\n*Last checked: ${formatDisplayTime()}*`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.trim();
|
return reply.trim();
|
||||||
|
|
@ -272,7 +272,7 @@ export function renderDectDiagnosticsMarkdown(results, opts = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (footer) {
|
if (footer) {
|
||||||
out += `\n*Base diagnostics pulled at ${new Date().toLocaleTimeString()} via the DECT relay. Use \`/dectstatus ${storeNum}\` for full details and reboot/factory-reset controls.*`;
|
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();
|
return out.trim();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@
|
||||||
// The renderer never emits an adaptive card itself. The caller
|
// The renderer never emits an adaptive card itself. The caller
|
||||||
// (commands/voiceDiag.js) walks the same results array to post cards.
|
// (commands/voiceDiag.js) walks the same results array to post cards.
|
||||||
|
|
||||||
|
import { formatDisplayTime } from '../../utils/time.js';
|
||||||
|
|
||||||
const SEVERITY_ORDER = ['error', 'warn', 'skipped', 'ok'];
|
const SEVERITY_ORDER = ['error', 'warn', 'skipped', 'ok'];
|
||||||
const SEVERITY_LABEL = {
|
const SEVERITY_LABEL = {
|
||||||
error: 'ERRORS',
|
error: 'ERRORS',
|
||||||
|
|
@ -118,8 +120,7 @@ export function renderVoiceDiagMarkdown(results, opts = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emitFooter) {
|
if (emitFooter) {
|
||||||
const now = new Date();
|
reply += `_Last checked: ${formatDisplayTime()}_\n`;
|
||||||
reply += `_Last checked: ${now.toISOString()}_\n`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.trim();
|
return reply.trim();
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
rollupAlarms as sharedRollupAlarms,
|
rollupAlarms as sharedRollupAlarms,
|
||||||
humanizeAge,
|
humanizeAge,
|
||||||
} from '../enrichment/alarmSemantics.js';
|
} from '../enrichment/alarmSemantics.js';
|
||||||
|
import { formatDisplayTime } from '../../utils/time.js';
|
||||||
|
|
||||||
// Thresholds are read from env at render time so the rendered
|
// Thresholds are read from env at render time so the rendered
|
||||||
// icons stay in sync with the check bucket verdicts. Same defaults
|
// icons stay in sync with the check bucket verdicts. Same defaults
|
||||||
|
|
@ -152,7 +153,7 @@ export function renderWanDiagnosticsMarkdown(data, opts = {}) {
|
||||||
|
|
||||||
if (footer) {
|
if (footer) {
|
||||||
const store = storeNum || data.storeNum;
|
const store = storeNum || data.storeNum;
|
||||||
out += `\n*WAN metrics pulled at ${new Date().toLocaleTimeString()} from Prisma SD-WAN. ` +
|
out += `\n*WAN metrics pulled at ${formatDisplayTime()} from Prisma SD-WAN. ` +
|
||||||
`Use \`/voicediag ${store} --only wanLatency,wanJitter,wanLoss,wanMos,wanHealthscore,wanLinkState,wanAlarms\` for link-probe breakdowns, ` +
|
`Use \`/voicediag ${store} --only wanLatency,wanJitter,wanLoss,wanMos,wanHealthscore,wanLinkState,wanAlarms\` for link-probe breakdowns, ` +
|
||||||
`or \`/voicediag ${store} --only wanAppRtpMos,wanAppRtpLoss,wanAppRtpJitter\` for per-app RTP quality.*`;
|
`or \`/voicediag ${store} --only wanAppRtpMos,wanAppRtpLoss,wanAppRtpJitter\` for per-app RTP quality.*`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
renderDectDiagnosticsMarkdown,
|
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';
|
||||||
|
|
||||||
// Timestamp exactly 3 hours in the past — makes `simpleTimeAgo`
|
// Timestamp exactly 3 hours in the past — makes `simpleTimeAgo`
|
||||||
// deterministic to "3 hours ago" for the duration of this test run.
|
// deterministic to "3 hours ago" for the duration of this test run.
|
||||||
|
|
@ -405,3 +406,90 @@ test('dect diagnostics renderer: footer references /dectstatus command by store'
|
||||||
const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782' });
|
const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782' });
|
||||||
assert.match(md, /Use `\/dectstatus 782`/);
|
assert.match(md, /Use `\/dectstatus 782`/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Full /dectstatus dump (renderDectStatusMarkdown)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fullOkResult = () => ({
|
||||||
|
base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' },
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
device: {
|
||||||
|
model: 'DBS-210-3PC',
|
||||||
|
macAddress: '6c:ab:05:f6:28:19',
|
||||||
|
ipAddress: '10.4.11.87',
|
||||||
|
rfpiAddress: '13508C9C; RPN:00',
|
||||||
|
},
|
||||||
|
firmware: { version: 'IPDECT-V2/05-01-03-0101-09' },
|
||||||
|
time: { operatingTime: '02:15:00 (H:M:S)', currentLocalTime: '02-Jul-2026 14:00:36' },
|
||||||
|
multiCell: { role: 'primary' },
|
||||||
|
conflictInfo: 'No Conflict',
|
||||||
|
baseStatus: 'ok',
|
||||||
|
rebootLog: [
|
||||||
|
{ sequence: 164, at: '2026-07-02T12:54:12', reasonName: 'Power Loss', reasonCode: 80, firmwareAtBoot: '05-01-03-0101-09' },
|
||||||
|
{ sequence: 163, at: '2026-07-02T12:49:50', reasonName: 'Normal Reboot', reasonCode: 21, firmwareAtBoot: '05-01-03-0101-09' },
|
||||||
|
],
|
||||||
|
network: { txPackets: 100, rxPackets: 200, rxDropped: 18, rxErrors: 0, txErrors: 0 },
|
||||||
|
rtp: { total: 2, current: 0, currentLocal: 0, currentRelay: 0 },
|
||||||
|
security: {
|
||||||
|
customCa: { installed: false },
|
||||||
|
dot1x: { protocol: 'N/A', transactionStatus: 'Unavailable' },
|
||||||
|
},
|
||||||
|
emergencyNumbers: ['911', '1911'],
|
||||||
|
},
|
||||||
|
verdict: {
|
||||||
|
healthy: false,
|
||||||
|
warnings: ['1 recent power-loss reboot(s); most recent at 2026-07-02T12:54:12'],
|
||||||
|
info: ['Rx dropped packets: 18 since last boot'],
|
||||||
|
},
|
||||||
|
elapsedMs: 812,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dect status renderer: full dump includes reboot log, emergency numbers, and verdict', () => {
|
||||||
|
const md = renderDectStatusMarkdown([fullOkResult()], {
|
||||||
|
storeNum: '782',
|
||||||
|
footer: false,
|
||||||
|
relay: { connected: true, agent: { hostname: 'dc-relay-1' } },
|
||||||
|
});
|
||||||
|
assert.match(md, /\*\*DECT Status — Store 782\*\*/);
|
||||||
|
assert.match(md, /Relay: online \(dc-relay-1\)/);
|
||||||
|
assert.match(md, /✅|⚠️ \*\*Basestation A\*\*/);
|
||||||
|
assert.match(md, /\*\*Reboot log\*\*/);
|
||||||
|
assert.match(md, /⚡ #164/);
|
||||||
|
assert.match(md, /Power Loss/);
|
||||||
|
assert.match(md, /911, 1911/);
|
||||||
|
assert.match(md, /healthy: \*\*NO\*\*/);
|
||||||
|
assert.match(md, /Rx dropped packets: 18/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dect status renderer: empty discovery + offline relay still produces a usable message', () => {
|
||||||
|
const md = renderDectStatusMarkdown([], {
|
||||||
|
storeNum: '782',
|
||||||
|
footer: false,
|
||||||
|
relay: { connected: false },
|
||||||
|
discoveryWarnings: [{ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.5', reason: 'not on 10.x' }],
|
||||||
|
});
|
||||||
|
assert.match(md, /Relay: \*\*offline\*\*/);
|
||||||
|
assert.match(md, /No reachable DECT basestations/);
|
||||||
|
assert.match(md, /not on 10\.x/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dect status renderer: collect failure surfaces error + hint', () => {
|
||||||
|
const md = renderDectStatusMarkdown([
|
||||||
|
{
|
||||||
|
base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' },
|
||||||
|
ok: false,
|
||||||
|
data: null,
|
||||||
|
verdict: null,
|
||||||
|
elapsedMs: 15000,
|
||||||
|
error: {
|
||||||
|
code: 'NOT_CONNECTED',
|
||||||
|
message: 'DECT relay agent is not connected',
|
||||||
|
hint: 'DECT relay agent is not connected. Check that dect-relay-agent is running in the data center.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], { storeNum: '782', footer: false });
|
||||||
|
assert.match(md, /Collect failed: DECT relay agent is not connected/);
|
||||||
|
assert.match(md, /dect-relay-agent is running/);
|
||||||
|
});
|
||||||
|
|
|
||||||
26
tests/time.test.js
Normal file
26
tests/time.test.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { formatDisplayTime, DISPLAY_TIMEZONE } from '../utils/time.js';
|
||||||
|
|
||||||
|
test('DISPLAY_TIMEZONE defaults to America/New_York', () => {
|
||||||
|
// If DISPLAY_TIMEZONE env is unset in test runner, we expect the default.
|
||||||
|
const expected = process.env.DISPLAY_TIMEZONE || 'America/New_York';
|
||||||
|
assert.equal(DISPLAY_TIMEZONE, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatDisplayTime: converts UTC instant to Eastern wall clock', () => {
|
||||||
|
// 2026-07-21 18:13:45 UTC → 2:13:45 PM EDT (DST)
|
||||||
|
const d = new Date('2026-07-21T18:13:45.000Z');
|
||||||
|
const formatted = formatDisplayTime(d);
|
||||||
|
assert.match(formatted, /2:13:45 PM/);
|
||||||
|
assert.match(formatted, /EDT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatDisplayTime: winter offset uses EST', () => {
|
||||||
|
// 2026-01-15 18:00:00 UTC → 1:00:00 PM EST
|
||||||
|
const d = new Date('2026-01-15T18:00:00.000Z');
|
||||||
|
const formatted = formatDisplayTime(d);
|
||||||
|
assert.match(formatted, /1:00:00 PM/);
|
||||||
|
assert.match(formatted, /EST/);
|
||||||
|
});
|
||||||
|
|
@ -118,12 +118,12 @@ test('renderer: emitFooter=false suppresses trailing timestamp', () => {
|
||||||
assert.equal(md.includes('Last checked'), false);
|
assert.equal(md.includes('Last checked'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renderer: emitFooter=true (default) adds an ISO timestamp line', () => {
|
test('renderer: emitFooter=true (default) adds a display-timezone timestamp line', () => {
|
||||||
const md = renderVoiceDiagMarkdown([R('c1', 'ok', 'good')], {
|
const md = renderVoiceDiagMarkdown([R('c1', 'ok', 'good')], {
|
||||||
storeNum: '99',
|
storeNum: '99',
|
||||||
detailed: true,
|
detailed: true,
|
||||||
});
|
});
|
||||||
assert.match(md, /_Last checked: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
assert.match(md, /_Last checked: .+ (AM|PM) [A-Z]{2,5}_/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renderer: details values — arrays truncated past 3 items, nested objects JSON-ified', () => {
|
test('renderer: details values — arrays truncated past 3 items, nested objects JSON-ified', () => {
|
||||||
|
|
|
||||||
64
utils/pendingDectActions.js
Normal file
64
utils/pendingDectActions.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// src/utils/pendingDectActions.js
|
||||||
|
//
|
||||||
|
// In-memory store for pending DECT base-station action confirmation
|
||||||
|
// cards (reboot / force-reboot / factory-reset / etc.). Same TTL
|
||||||
|
// sweep pattern as pendingIgmpFixes / pendingOffboards.
|
||||||
|
//
|
||||||
|
// Entries typically hold:
|
||||||
|
// {
|
||||||
|
// storeNum, baseIp, baseMac, baseName,
|
||||||
|
// dectAction, // 'reboot' | 'force-reboot' | ...
|
||||||
|
// requester, // from extractRequester(trigger)
|
||||||
|
// timestamp, // auto-set
|
||||||
|
// }
|
||||||
|
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
const TTL_MS = 15 * 60 * 1000;
|
||||||
|
const SWEEP_INTERVAL_MS = 60 * 1000;
|
||||||
|
|
||||||
|
const _store = new Map();
|
||||||
|
|
||||||
|
export const pendingDectActions = {
|
||||||
|
set(cardId, data) {
|
||||||
|
_store.set(cardId, { ...data, timestamp: Date.now() });
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
get(cardId) {
|
||||||
|
return _store.get(cardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
has(cardId) {
|
||||||
|
return _store.has(cardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(cardId) {
|
||||||
|
return _store.delete(cardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
get size() {
|
||||||
|
return _store.size;
|
||||||
|
},
|
||||||
|
|
||||||
|
entries() {
|
||||||
|
return _store.entries();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sweepHandle = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [cardId, data] of _store.entries()) {
|
||||||
|
const stamped = typeof data?.timestamp === 'number' ? data.timestamp : 0;
|
||||||
|
if (now - stamped > TTL_MS) {
|
||||||
|
logger(
|
||||||
|
'dect:cleanup',
|
||||||
|
`Expired card ${cardId} for store ${data?.storeNum || 'unknown'} ` +
|
||||||
|
`base ${data?.baseIp || 'unknown'} action ${data?.dectAction || 'unknown'}`,
|
||||||
|
);
|
||||||
|
_store.delete(cardId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, SWEEP_INTERVAL_MS);
|
||||||
|
|
||||||
|
sweepHandle.unref?.();
|
||||||
|
|
@ -1,6 +1,32 @@
|
||||||
// utils/time.js
|
// utils/time.js
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IANA timezone for human-facing footer timestamps ("Last checked", etc.).
|
||||||
|
* The bot process often runs in UTC (Docker default); this keeps chat
|
||||||
|
* output in operator-local time without requiring TZ on the container.
|
||||||
|
* Override via DISPLAY_TIMEZONE in .env.
|
||||||
|
*/
|
||||||
|
export const DISPLAY_TIMEZONE = process.env.DISPLAY_TIMEZONE || 'America/New_York';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Date for chat footers ("Last checked: …"). Uses DISPLAY_TIMEZONE
|
||||||
|
* and includes a short zone label (e.g. "EDT") so UTC-looking output is obvious.
|
||||||
|
*
|
||||||
|
* @param {Date} [date=new Date()]
|
||||||
|
* @returns {string} e.g. "2:13:45 PM EDT"
|
||||||
|
*/
|
||||||
|
export function formatDisplayTime(date = new Date()) {
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
timeZone: DISPLAY_TIMEZONE,
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
timeZoneName: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human-readable "X time ago" from ISO string or Date
|
* Human-readable "X time ago" from ISO string or Date
|
||||||
* @param {string|Date|number} input - ISO string, Date object, or timestamp
|
* @param {string|Date|number} input - ISO string, Date object, or timestamp
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue