Compare commits
No commits in common. "7c7d0a898caa4a67f463e774d0d27c2b3e6ddb56" and "9d0dbb071e3f51ef88324657ef04eb5ac161e168" have entirely different histories.
7c7d0a898c
...
9d0dbb071e
19 changed files with 16 additions and 1000 deletions
|
|
@ -11,11 +11,6 @@ SERVER_PORT=1800
|
|||
# Logging level: info (default - clean), debug (verbose, includes per-fetch details)
|
||||
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.
|
||||
# WEBEX_FRAMEWORK_DEBUG=false
|
||||
|
||||
|
|
|
|||
|
|
@ -1,418 +0,0 @@
|
|||
// 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,7 +19,6 @@ const SHORT_HELP = {
|
|||
// AV / phones
|
||||
avstatus: 'AV / device status for a store (alias: /wostatus)',
|
||||
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
|
||||
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
|
||||
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
|
||||
|
||||
// Jira
|
||||
|
|
@ -60,32 +59,10 @@ const LONG_HELP = {
|
|||
'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).',
|
||||
'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>`.',
|
||||
'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: {
|
||||
title: '/voicediag',
|
||||
usage: [
|
||||
|
|
@ -253,7 +230,7 @@ const LONG_HELP = {
|
|||
|
||||
const GROUPS = [
|
||||
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
|
||||
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag'] },
|
||||
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'voicediag'] },
|
||||
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
|
||||
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
|
||||
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
import { summarizeJiraTicket } from '../services/jiraSummarizer.js';
|
||||
import { analyzeCommonIssues } from '../services/jiraSummarizer.js';
|
||||
import jira from '../integrations/jira/JiraClient.js';
|
||||
import { formatDisplayTime } from '../utils/time.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const MAX_TICKETS = 20;
|
||||
|
|
@ -135,7 +134,7 @@ export async function handleJiraHistory(bot, trigger) {
|
|||
}
|
||||
}
|
||||
|
||||
reply += `\n*Last checked: ${formatDisplayTime()}*`;
|
||||
reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`;
|
||||
await bot.say('markdown', reply.trim());
|
||||
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import jira from '../integrations/jira/JiraClient.js';
|
|||
import { summarizeJiraTicket } from '../services/jiraSummarizer.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { getStatusEmoji, calculateDaysOpen } from '../services/jiraService.js';
|
||||
import { formatDisplayTime } from '../utils/time.js';
|
||||
|
||||
export async function handleJiraTicket(bot, trigger) {
|
||||
logger('jira:ticket', 'Handler entered', 'debug');
|
||||
|
|
@ -45,7 +44,7 @@ export async function handleJiraTicket(bot, trigger) {
|
|||
reply += `${aiSummary}\n\n`;
|
||||
reply += `${statusEmoji} **Status:** ${fields.status?.name || '—'} • Component: ${component}\n`;
|
||||
reply += `Assigned: ${assignee} • Open for: ${days} days\n\n`;
|
||||
reply += `\n*Last checked: ${formatDisplayTime()}*`;
|
||||
reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`;
|
||||
|
||||
await bot.say('markdown', reply.trim());
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
import { handleHelp } from './help.js';
|
||||
import { handleAvStatus } from './avStatus.js';
|
||||
import { handlePhoneStatus } from './phoneStatus.js';
|
||||
import { handleDectStatus } from './dectStatus.js';
|
||||
import { handleProvisionDect } from './provisionDect.js';
|
||||
import { handleWoHistory } from './woHistory.js';
|
||||
import { handleWoSummary } from './woSummary.js';
|
||||
|
|
@ -45,10 +44,6 @@ export const commands = [
|
|||
|
||||
{ name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false },
|
||||
{ name: 'phonestatus', handler: handlePhoneStatus, mutating: false },
|
||||
// /dectstatus — full DECT base dump via the on-prem relay + chat-only
|
||||
// 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
|
||||
// /v1/people/{id}/features/* and offers per-issue adaptive-card
|
||||
// remediation. Reads are non-mutating but the confirm buttons on
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// src/commands/woHistory.js
|
||||
import { collectWoHistory } from '../services/woService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { formatDisplayTime } from '../utils/time.js';
|
||||
|
||||
export async function handleWoHistory(bot, trigger) {
|
||||
logger('wo:history', 'Handler entered');
|
||||
|
|
@ -53,7 +52,7 @@ export async function handleWoHistory(bot, trigger) {
|
|||
}
|
||||
}
|
||||
|
||||
reply += `\n*Last checked: ${formatDisplayTime()}*`;
|
||||
reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`;
|
||||
|
||||
await bot.say('markdown', reply.trim() || 'No data available.');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// src/commands/woSummary.js
|
||||
import { collectWoSummary } from '../services/woService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { formatDisplayTime } from '../utils/time.js';
|
||||
|
||||
export async function handleWoSummary(bot, trigger) {
|
||||
logger('wo:summary', 'Handler entered');
|
||||
|
|
@ -30,7 +29,7 @@ export async function handleWoSummary(bot, trigger) {
|
|||
|
||||
let reply = `**Work Order Summary – ${woNumber}**\n\n`;
|
||||
reply += `${summary || 'No summary data available.'}\n`;
|
||||
reply += `\n*Last checked: ${formatDisplayTime()}*`;
|
||||
reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`;
|
||||
|
||||
await bot.say('markdown', reply.trim());
|
||||
|
||||
|
|
|
|||
127
index.js
127
index.js
|
|
@ -20,10 +20,6 @@ import { requireApiToken, requireAuthForAllCommands } from './utils/httpAuth.js'
|
|||
// command fallback.
|
||||
import { handleUnknown } from './commands/unknownCommand.js';
|
||||
import { handleDectProvisionAction } from './commands/provisionDect.js';
|
||||
import {
|
||||
handleDectStatusAction,
|
||||
DECT_STATUS_CARD_ACTIONS,
|
||||
} from './commands/dectStatus.js';
|
||||
import {
|
||||
applyOffboardConfirmation,
|
||||
cancelOffboardCard,
|
||||
|
|
@ -368,23 +364,6 @@ framework.on('attachmentAction', async (bot, trigger) => {
|
|||
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 ──
|
||||
if (OFFBOARD_ACTIONS.has(actionType)) {
|
||||
const { cardId } = action.inputs;
|
||||
|
|
@ -589,102 +568,8 @@ framework.on('attachmentAction', async (bot, trigger) => {
|
|||
|
||||
framework.on("initialized", () => {
|
||||
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)
|
||||
// ──────────────────────────────────────────────
|
||||
|
|
@ -767,7 +652,7 @@ if (process.env.DECT_RELAY_AGENT_TOKEN) {
|
|||
} else {
|
||||
logger(
|
||||
'startup',
|
||||
'DECT relay disabled — set DECT_RELAY_AGENT_TOKEN in .env (and share the same value with dect-relay-agent) to enable /phonestatus DECT follow-up + /dectstatus',
|
||||
'DECT relay disabled — set DECT_RELAY_AGENT_TOKEN in .env (and share the same value with dect-relay-agent) to enable /phonestatus DECT follow-up + future /dectstatus command',
|
||||
'warn',
|
||||
);
|
||||
}
|
||||
|
|
@ -893,16 +778,6 @@ process.on('uncaughtException', (err) => {
|
|||
|
||||
process.on('unhandledRejection', (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');
|
||||
shutdown('unhandledRejection', 1);
|
||||
});
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
// handler — same wired-vs-wireless branch, same double-arrow indent
|
||||
// convention, same fallback for `{client:...}` vs flat shapes.
|
||||
|
||||
import { simpleTimeAgo, formatDisplayTime } from '../../utils/time.js';
|
||||
import { simpleTimeAgo } from '../../utils/time.js';
|
||||
|
||||
/**
|
||||
* Render an AV device-status markdown snapshot from a
|
||||
|
|
@ -250,7 +250,7 @@ export function renderAvStatusMarkdown(data, opts = {}) {
|
|||
}
|
||||
|
||||
if (footer) {
|
||||
reply += `*Last checked: ${formatDisplayTime()}*`;
|
||||
reply += `*Last checked: ${new Date().toLocaleTimeString()}*`;
|
||||
}
|
||||
|
||||
return reply.trim();
|
||||
|
|
|
|||
|
|
@ -1,198 +0,0 @@
|
|||
// 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
|
||||
// times ("2h ago") stay consistent across chat and Jira surfaces.
|
||||
|
||||
import { simpleTimeAgo, formatBytes, formatDisplayTime } from '../../utils/time.js';
|
||||
import { simpleTimeAgo, formatBytes } from '../../utils/time.js';
|
||||
|
||||
/**
|
||||
* Render a phone-status markdown snapshot from a `collectPhoneStatus`
|
||||
|
|
@ -232,7 +232,7 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
|
|||
}
|
||||
|
||||
if (footer) {
|
||||
reply += `\n*Last checked: ${formatDisplayTime()}*`;
|
||||
reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`;
|
||||
}
|
||||
|
||||
return reply.trim();
|
||||
|
|
@ -272,7 +272,7 @@ export function renderDectDiagnosticsMarkdown(results, opts = {}) {
|
|||
}
|
||||
|
||||
if (footer) {
|
||||
out += `\n*Base diagnostics pulled at ${formatDisplayTime()} via the DECT relay. Use \`/dectstatus ${storeNum}\` for full details and reboot/factory-reset controls.*`;
|
||||
out += `\n*Base diagnostics pulled at ${new Date().toLocaleTimeString()} via the DECT relay. Use \`/dectstatus ${storeNum}\` for full details and reboot/factory-reset controls.*`;
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,8 +34,6 @@
|
|||
// The renderer never emits an adaptive card itself. The caller
|
||||
// (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_LABEL = {
|
||||
error: 'ERRORS',
|
||||
|
|
@ -120,7 +118,8 @@ export function renderVoiceDiagMarkdown(results, opts = {}) {
|
|||
}
|
||||
|
||||
if (emitFooter) {
|
||||
reply += `_Last checked: ${formatDisplayTime()}_\n`;
|
||||
const now = new Date();
|
||||
reply += `_Last checked: ${now.toISOString()}_\n`;
|
||||
}
|
||||
|
||||
return reply.trim();
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import {
|
|||
rollupAlarms as sharedRollupAlarms,
|
||||
humanizeAge,
|
||||
} from '../enrichment/alarmSemantics.js';
|
||||
import { formatDisplayTime } from '../../utils/time.js';
|
||||
|
||||
// Thresholds are read from env at render time so the rendered
|
||||
// icons stay in sync with the check bucket verdicts. Same defaults
|
||||
|
|
@ -153,7 +152,7 @@ export function renderWanDiagnosticsMarkdown(data, opts = {}) {
|
|||
|
||||
if (footer) {
|
||||
const store = storeNum || data.storeNum;
|
||||
out += `\n*WAN metrics pulled at ${formatDisplayTime()} from Prisma SD-WAN. ` +
|
||||
out += `\n*WAN metrics pulled at ${new Date().toLocaleTimeString()} from Prisma SD-WAN. ` +
|
||||
`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.*`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
renderDectDiagnosticsMarkdown,
|
||||
} from '../services/renderers/phoneStatusRenderer.js';
|
||||
import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js';
|
||||
import { renderDectStatusMarkdown } from '../services/renderers/dectStatusRenderer.js';
|
||||
|
||||
// Timestamp exactly 3 hours in the past — makes `simpleTimeAgo`
|
||||
// deterministic to "3 hours ago" for the duration of this test run.
|
||||
|
|
@ -406,90 +405,3 @@ 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 = () => ({
|
||||
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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
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);
|
||||
});
|
||||
|
||||
test('renderer: emitFooter=true (default) adds a display-timezone timestamp line', () => {
|
||||
test('renderer: emitFooter=true (default) adds an ISO timestamp line', () => {
|
||||
const md = renderVoiceDiagMarkdown([R('c1', 'ok', 'good')], {
|
||||
storeNum: '99',
|
||||
detailed: true,
|
||||
});
|
||||
assert.match(md, /_Last checked: .+ (AM|PM) [A-Z]{2,5}_/);
|
||||
assert.match(md, /_Last checked: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
test('renderer: details values — arrays truncated past 3 items, nested objects JSON-ified', () => {
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
// 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,32 +1,6 @@
|
|||
// utils/time.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
|
||||
* @param {string|Date|number} input - ISO string, Date object, or timestamp
|
||||
|
|
|
|||
Loading…
Reference in a new issue