collabSupport/commands/dectDiag.js
jmcqueen 7dc326c404 Split voice commands into status vs diag and redesign MPP phone output.
Replace /phonestatus follow-ups with /voicestatus, /wanstatus, /phonediag, and /dectdiag; extend /voicediag with relay probes and section-based MPP diagnostics.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:55:41 -04:00

394 lines
12 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/dectDiag.js
//
// /dectdiag <store> — full DECT basestation diagnostics via the
// on-prem relay agent: status.xml dump (device,
// firmware, reboot log, network, RTP) plus chat-only
// action cards for reboot / force-reboot / factory-reset.
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 handleDectDiag(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: `/dectdiag 782`\n' +
'HTTP: `?storeNum=782`',
);
return;
}
const filterRaw = (args[1] || query.base || query.ip || query.mac || '').trim();
logger('dect:diag', `Collecting DECT diagnostics for store ${storeNum}` +
(filterRaw ? ` (filter=${filterRaw})` : ''));
let relayStatus = null;
try {
if (process.env.DECT_RELAY_AGENT_TOKEN) {
relayStatus = getDectRelayHub().status();
}
} catch (err) {
logger('dect:diag', `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 `/dectdiag`.',
);
return;
}
let phoneData;
try {
phoneData = await collectPhoneStatus(storeNum);
} catch (err) {
logger('dect:diag', `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 diagnostics 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.');
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:diag',
`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:diag: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:diag: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:diag:action', `Missing cardId on ${actionType} — ignoring`);
return;
}
if (!pendingDectActions.has(cardId)) {
logger('dect:diag:action', `Card ${cardId} is expired or unknown`);
await bot.say('markdown', '⚠️ That action card has expired. Re-run `/dectdiag` and try again.');
return;
}
const data = pendingDectActions.get(cardId);
pendingDectActions.delete(cardId);
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;
}
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 \`/dectdiag ${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:diag:action', `Ignoring unhandled action type ${actionType}`, 'debug');
void roomId;
}
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 },
},
],
};
}
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;
});
}