Compare commits

..

2 commits

Author SHA1 Message Date
7c7d0a898c Add /dectstatus command with full base dump and reboot cards.
Pulls complete status.xml from each reachable DBS-210 via the relay,
renders the full CLI-style dump, and posts per-base adaptive cards for
reboot and factory-reset (confirm flow + audit logging). Wired into
registry, help, and attachmentAction dispatch.
2026-07-21 15:51:07 -04:00
1117be40cc Format chat footers in DISPLAY_TIMEZONE instead of UTC.
Docker hosts default to UTC, so bare toLocaleTimeString() showed
wrong "Last checked" times in avstatus and other commands. Add
formatDisplayTime() (default America/New_York, overridable via
DISPLAY_TIMEZONE) and use it across renderers and command footers.
2026-07-21 14:41:26 -04:00
19 changed files with 1000 additions and 16 deletions

View file

@ -11,6 +11,11 @@ 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

418
commands/dectStatus.js Normal file
View 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 24 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;
});
}

View file

@ -19,6 +19,7 @@ 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
@ -59,10 +60,32 @@ 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: [
@ -230,7 +253,7 @@ const LONG_HELP = {
const GROUPS = [
{ 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: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },

View file

@ -8,6 +8,7 @@ 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;
@ -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());
} catch (err) {

View file

@ -3,6 +3,7 @@ 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');
@ -44,7 +45,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: ${new Date().toLocaleTimeString()}*`;
reply += `\n*Last checked: ${formatDisplayTime()}*`;
await bot.say('markdown', reply.trim());

View file

@ -14,6 +14,7 @@
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';
@ -44,6 +45,10 @@ 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

View file

@ -1,6 +1,7 @@
// 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');
@ -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.');

View file

@ -1,6 +1,7 @@
// 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');
@ -29,7 +30,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: ${new Date().toLocaleTimeString()}*`;
reply += `\n*Last checked: ${formatDisplayTime()}*`;
await bot.say('markdown', reply.trim());

127
index.js
View file

@ -20,6 +20,10 @@ 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,
@ -364,6 +368,23 @@ 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;
@ -568,8 +589,102 @@ 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)
// ──────────────────────────────────────────────
@ -652,7 +767,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 + 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',
);
}
@ -778,6 +893,16 @@ 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);
});

View file

@ -20,7 +20,7 @@
// handler — same wired-vs-wireless branch, same double-arrow indent
// 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
@ -250,7 +250,7 @@ export function renderAvStatusMarkdown(data, opts = {}) {
}
if (footer) {
reply += `*Last checked: ${new Date().toLocaleTimeString()}*`;
reply += `*Last checked: ${formatDisplayTime()}*`;
}
return reply.trim();

View 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}`);
}

View file

@ -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 } from '../../utils/time.js';
import { simpleTimeAgo, formatBytes, formatDisplayTime } 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: ${new Date().toLocaleTimeString()}*`;
reply += `\n*Last checked: ${formatDisplayTime()}*`;
}
return reply.trim();
@ -272,7 +272,7 @@ export function renderDectDiagnosticsMarkdown(results, opts = {}) {
}
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();
}

View file

@ -34,6 +34,8 @@
// 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',
@ -118,8 +120,7 @@ export function renderVoiceDiagMarkdown(results, opts = {}) {
}
if (emitFooter) {
const now = new Date();
reply += `_Last checked: ${now.toISOString()}_\n`;
reply += `_Last checked: ${formatDisplayTime()}_\n`;
}
return reply.trim();

View file

@ -22,6 +22,7 @@ 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
@ -152,7 +153,7 @@ export function renderWanDiagnosticsMarkdown(data, opts = {}) {
if (footer) {
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, ` +
`or \`/voicediag ${store} --only wanAppRtpMos,wanAppRtpLoss,wanAppRtpJitter\` for per-app RTP quality.*`;
}

View file

@ -15,6 +15,7 @@ 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.
@ -405,3 +406,90 @@ 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/);
});

26
tests/time.test.js Normal file
View 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/);
});

View file

@ -118,12 +118,12 @@ test('renderer: emitFooter=false suppresses trailing timestamp', () => {
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')], {
storeNum: '99',
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', () => {

View 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?.();

View file

@ -1,6 +1,32 @@
// 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