// src/commands/dectStatus.js // // /dectstatus โ€” 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=. 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; }); }