collabSupport/commands/dectStatus.js
jmcqueen 2b8c4e06aa Improve /dectstatus with handset RF context and cleaner base cards.
Surface handset registrations and RSSI, tighten reboot health to 7 days,
and consolidate Base / Handsets & RF / Network & RTP sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:02:55 -04:00

420 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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) 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 { buildHandsetContext } from '../services/dectStatus/buildHandsetContext.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 handsetCtx = buildHandsetContext(phoneData);
const md = renderDectStatusMarkdown(results, {
storeNum,
relay: relayStatus,
discoveryWarnings,
handsetCtx,
});
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;
});
}