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.
418 lines
13 KiB
JavaScript
418 lines
13 KiB
JavaScript
// 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;
|
||
});
|
||
}
|