Multi-integration Webex chat/HTTP bot that unifies phone, AV, and network status for retail store support. Consolidates data from Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital signage, and OptiSigns into rich per-store status commands. Key surfaces: - /phonestatus, /avstatus — per-store phone & AV device reports with clickable Meraki deep-links and per-port detail. - /webexhost — check/assign Webex Meetings host licenses via the Service App; adaptive-card confirmation flow, HTTP-API-gated. - /offboarduser — full Webex Admin offboarding (auth revoke, device wipe, license removal); adaptive-card confirmation. - /jirapoll — on-demand trigger for the hourly Jira poller. - /bulkavstatuscsv — bulk store CSV export with concurrency limits. Automation: - Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier that categorizes unassigned tickets as phone/av/skip, extracts store numbers from free-text, and enriches Jira with the same detailed markdown the chat commands emit (converted to Jira ADF, preserves bold + Meraki links). Idempotent via a `bot-enriched` Jira label. Architecture: - Node.js 20+, ESM, Express 5, webex-node-bot-framework. - Layered integrations (integrations/*), services (services/*), commands (commands/*), utils (utils/*). - Shared markdown renderers (services/renderers/*) feed both chat handlers and the Jira poller so the two surfaces stay in sync. - Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) — no new npm dependency. - Node built-in test runner (`node --test tests/*.test.js`), 30 tests covering the converter, renderers, and poller ADF assembly. Docker + docker-compose deployment. Config via .env (see .env.example for the full option surface).
397 lines
No EOL
13 KiB
JavaScript
397 lines
No EOL
13 KiB
JavaScript
// src/commands/provisionDect.js
|
||
import {
|
||
findDectNetworkForStore,
|
||
getDectProvisioningStatus,
|
||
addDectBasestation,
|
||
removeDectBasestation,
|
||
addDectHandset,
|
||
removeDectHandset,
|
||
generateDectAccessCode
|
||
} from '../services/phoneService.js';
|
||
import { logger } from '../utils/logger.js';
|
||
|
||
function buildProvisioningCard(storeNum, network, basestations = [], handsets = []) {
|
||
const padded = String(storeNum).padStart(4, '0');
|
||
const networkName = network?.name || `Store ${padded}`;
|
||
|
||
const baseFacts = [
|
||
{ title: 'Basestations', value: String(basestations.length) },
|
||
{ title: 'Handsets', value: String(handsets.length) },
|
||
{ title: 'Location', value: network?.locationName || network?.location?.name || '—' }
|
||
];
|
||
|
||
const baseList = basestations.length
|
||
? basestations.map(b => ({
|
||
type: 'TextBlock',
|
||
text: `• ${b.mac || '—'} | ${b.status || 'unknown'} | IP: ${b.ipAddress || '—'}`,
|
||
size: 'Small'
|
||
}))
|
||
: [{ type: 'TextBlock', text: 'None', size: 'Small', color: 'Attention' }];
|
||
|
||
const handsetList = handsets.length
|
||
? handsets.map(h => ({
|
||
type: 'TextBlock',
|
||
text: `• ${h.index ? h.index + '-' : ''}${h.extension || h.accessCode || '—'} (${h.name || ''}) @ Base ${h.baseMac || h.baseStationId || 'unassigned'}`,
|
||
size: 'Small'
|
||
}))
|
||
: [{ type: 'TextBlock', text: 'None', size: 'Small', color: 'Attention' }];
|
||
|
||
// Choices for remove (use ids) - will be turned into checkboxes
|
||
const baseChoices = basestations.map(b => ({
|
||
title: `${b.mac} (${b.status})`,
|
||
value: b.id
|
||
}));
|
||
const handsetChoices = handsets.map(h => ({
|
||
title: `${h.index ? h.index + '-' : ''}${h.extension || h.accessCode || '—'}`,
|
||
value: h.id
|
||
}));
|
||
|
||
return {
|
||
type: 'AdaptiveCard',
|
||
version: '1.3',
|
||
body: [
|
||
{
|
||
type: 'TextBlock',
|
||
text: `DECT Provisioning - Store ${padded}`,
|
||
weight: 'Bolder',
|
||
size: 'Large'
|
||
},
|
||
{
|
||
type: 'TextBlock',
|
||
text: `Network: ${networkName}`,
|
||
size: 'Medium'
|
||
},
|
||
{
|
||
type: 'FactSet',
|
||
facts: baseFacts
|
||
},
|
||
{
|
||
type: 'TextBlock',
|
||
text: 'Current Basestations',
|
||
weight: 'Bolder',
|
||
spacing: 'Medium'
|
||
},
|
||
...baseList,
|
||
{
|
||
type: 'TextBlock',
|
||
text: 'Current Handsets',
|
||
weight: 'Bolder',
|
||
spacing: 'Medium'
|
||
},
|
||
...handsetList,
|
||
{
|
||
type: 'TextBlock',
|
||
text: 'Add Basestation(s) (comma/space separated MACs)',
|
||
weight: 'Bolder',
|
||
spacing: 'Medium'
|
||
},
|
||
{
|
||
type: 'Input.Text',
|
||
id: 'baseMacs',
|
||
placeholder: '001122334455, AABBCCDDEEFF'
|
||
},
|
||
{
|
||
type: 'TextBlock',
|
||
text: 'Add Handset',
|
||
weight: 'Bolder',
|
||
spacing: 'Medium'
|
||
},
|
||
{
|
||
type: 'TextBlock',
|
||
text: 'Remove - use checkboxes (multi-select supported). Confirmation will be required.',
|
||
weight: 'Bolder',
|
||
spacing: 'Medium'
|
||
},
|
||
{
|
||
type: 'Input.ChoiceSet',
|
||
id: 'removeBases',
|
||
isMultiSelect: true,
|
||
style: 'expanded',
|
||
choices: baseChoices.length ? baseChoices : [{ title: 'None', value: '' }]
|
||
},
|
||
{
|
||
type: 'Input.ChoiceSet',
|
||
id: 'removeHandsets',
|
||
isMultiSelect: true,
|
||
style: 'expanded',
|
||
choices: handsetChoices.length ? handsetChoices : [{ title: 'None', value: '' }]
|
||
},
|
||
|
||
],
|
||
actions: [
|
||
{
|
||
type: 'Action.Submit',
|
||
title: '➕ Add Basestation(s)',
|
||
data: { action: 'add-bases', storeNumber: String(storeNum) }
|
||
},
|
||
{
|
||
type: 'Action.Submit',
|
||
title: '➕ Add Handset',
|
||
data: { action: 'add-handset', storeNumber: String(storeNum) }
|
||
},
|
||
{
|
||
type: 'Action.Submit',
|
||
title: '🗑 Remove Selected Basestations (will confirm)',
|
||
data: { action: 'remove-bases', storeNumber: String(storeNum) }
|
||
},
|
||
{
|
||
type: 'Action.Submit',
|
||
title: '🗑 Remove Selected Handsets (will confirm)',
|
||
data: { action: 'remove-handsets', storeNumber: String(storeNum) }
|
||
},
|
||
{
|
||
type: 'Action.Submit',
|
||
title: '🔄 Refresh Status',
|
||
data: { action: 'refresh', storeNumber: String(storeNum) }
|
||
}
|
||
]
|
||
};
|
||
}
|
||
|
||
|
||
|
||
export async function handleProvisionDect(bot, trigger) {
|
||
logger('phone:provision', 'Handler entered');
|
||
|
||
const args = trigger.args || [];
|
||
const query = trigger.query || {};
|
||
let storeNum = (args[0] || query.storeNum || query.store || query.s || '').trim();
|
||
|
||
if (!storeNum || !/^\d{3,4}$/.test(storeNum)) {
|
||
await bot.say('markdown', '**Usage:** `/provision-dect 1234` (3-4 digit store number)\nNetwork must already exist as "Store XXXX".');
|
||
return;
|
||
}
|
||
|
||
logger('phone:provision', `Starting provisioning for store ${storeNum}`);
|
||
|
||
try {
|
||
const status = await getDectProvisioningStatus(storeNum);
|
||
const { network, basestations, handsets } = status;
|
||
|
||
if (!network) {
|
||
await bot.say('markdown', `❌ No DECT network found for Store ${storeNum} (looked up via 5-digit person). Ensure the network "Store ${storeNum.padStart(4, '0')}" exists.`);
|
||
return;
|
||
}
|
||
|
||
const card = buildProvisioningCard(storeNum, network, basestations, handsets);
|
||
|
||
await bot.say({
|
||
markdown: `DECT Provisioning for Store ${storeNum}`,
|
||
attachments: [{
|
||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||
content: card
|
||
}]
|
||
});
|
||
} catch (err) {
|
||
logger('phone:provision', `Error in handler for ${storeNum}: ${err.message}`, 'error');
|
||
await bot.say('markdown', `❌ Error: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
// Handle attachment actions for provisioning
|
||
export async function handleDectProvisionAction(bot, trigger) {
|
||
const action = trigger.attachmentAction;
|
||
if (!action || !action.inputs) return;
|
||
|
||
const inputs = action.inputs;
|
||
const actionType = inputs.action;
|
||
const storeNumber = inputs.storeNumber;
|
||
if (!actionType || !storeNumber) return;
|
||
|
||
const roomId = trigger.roomId || action.roomId;
|
||
logger('phone:provision', `Action ${actionType} for store ${storeNumber}`);
|
||
|
||
try {
|
||
const status = await getDectProvisioningStatus(storeNumber);
|
||
let { network, basestations = [], handsets = [] } = status;
|
||
|
||
if (!network) {
|
||
await bot.say('markdown', '❌ Network no longer found. Ignore any stale cards.', roomId);
|
||
return;
|
||
}
|
||
|
||
const locationId = network.locationId || network.location?.id;
|
||
const networkId = network.id;
|
||
|
||
// Remove the card message that was acted on (to prevent stale clicks)
|
||
// Use bot.censor which uses the bot's token and checks permissions
|
||
const messageId = trigger.attachmentAction.messageId;
|
||
if (messageId) {
|
||
try {
|
||
await bot.censor(messageId);
|
||
logger('phone:provision', `Removed stale provisioning card message ${messageId}`);
|
||
} catch (delErr) {
|
||
logger('phone:provision', `Could not remove previous card message: ${delErr.message}`, 'warn');
|
||
}
|
||
}
|
||
|
||
let resultTitle = '';
|
||
let resultMsg = '';
|
||
let isRemoveAction = false;
|
||
let selectedForConfirm = [];
|
||
|
||
const staleNote = '⚠️ This is a fresh updated view. Any previous DECT provisioning cards for this store are now stale — please ignore them.';
|
||
|
||
if (actionType === 'refresh') {
|
||
resultTitle = '✅ Status Refreshed';
|
||
resultMsg = 'Current DECT status updated below.';
|
||
} else if (actionType === 'add-bases') {
|
||
const macs = inputs.baseMacs || '';
|
||
if (!macs) {
|
||
resultTitle = '⚠️ No MACs provided';
|
||
resultMsg = 'Enter MAC(s) to add.';
|
||
} else {
|
||
const results = await addDectBasestation(locationId, networkId, macs);
|
||
const added = results.filter(r => r.success).length;
|
||
const skipped = results.filter(r => r.alreadyExists).length;
|
||
const failed = results.filter(r => r.error).length;
|
||
resultTitle = '✅ Add Basestation(s)';
|
||
resultMsg = `Added: ${added}, Skipped (exists): ${skipped}, Failed: ${failed}`;
|
||
}
|
||
} else if (actionType === 'add-handset') {
|
||
// Auto extension: always 5 + 4-digit padded store number (e.g. store 782 -> 50782)
|
||
const padded4 = String(storeNumber).padStart(4, '0');
|
||
const ext = '5' + padded4;
|
||
const code = generateDectAccessCode(storeNumber);
|
||
// display name = extension (per requirements)
|
||
const res = await addDectHandset(locationId, networkId, {
|
||
displayName: ext,
|
||
accessCode: code
|
||
// no baseStationId
|
||
});
|
||
if (res?.alreadyExists) {
|
||
resultTitle = 'ℹ️ Already exists';
|
||
resultMsg = `Handset with access code or similar for extension ${ext} already present.`;
|
||
} else {
|
||
resultTitle = '✅ Handset Added';
|
||
resultMsg = `Added handset for extension ${ext}. Access code: ${code} (per store rules). Refresh to see updated list.`;
|
||
}
|
||
} else if (actionType === 'remove-bases' || actionType === 'remove-handsets') {
|
||
isRemoveAction = true;
|
||
const isBase = actionType === 'remove-bases';
|
||
let rawSelected = isBase ? inputs.removeBases : inputs.removeHandsets;
|
||
const selected = Array.isArray(rawSelected) ? rawSelected : (rawSelected ? [rawSelected] : []);
|
||
if (!selected || selected.length === 0) {
|
||
resultTitle = '⚠️ Nothing selected';
|
||
resultMsg = 'Select items using checkboxes to remove.';
|
||
} else {
|
||
// Send confirmation card instead of deleting immediately
|
||
const itemsList = selected.map(id => {
|
||
if (isBase) {
|
||
const b = basestations.find(x => x.id === id);
|
||
return b ? `${b.mac} (${b.status})` : id;
|
||
} else {
|
||
const h = handsets.find(x => x.id === id);
|
||
const label = h ? `${h.index ? h.index + '-' : ''}${h.extension || h.accessCode || ''}` : id;
|
||
return label;
|
||
}
|
||
}).join(', ');
|
||
|
||
const confirmAction = isBase ? 'confirm-remove-bases' : 'confirm-remove-handsets';
|
||
const confirmCard = {
|
||
type: 'AdaptiveCard',
|
||
version: '1.3',
|
||
body: [
|
||
{
|
||
type: 'TextBlock',
|
||
text: `⚠️ CONFIRM DELETE`,
|
||
weight: 'Bolder',
|
||
size: 'Large',
|
||
color: 'Attention'
|
||
},
|
||
{
|
||
type: 'TextBlock',
|
||
text: `You are about to permanently remove the following ${isBase ? 'basestation(s)' : 'handset(s)'} for Store ${storeNumber}:`,
|
||
wrap: true
|
||
},
|
||
{
|
||
type: 'TextBlock',
|
||
text: itemsList,
|
||
wrap: true
|
||
},
|
||
{
|
||
type: 'TextBlock',
|
||
text: 'This action cannot be undone. The provisioning card will be refreshed after.',
|
||
wrap: true,
|
||
color: 'Attention'
|
||
}
|
||
],
|
||
actions: [
|
||
{
|
||
type: 'Action.Submit',
|
||
title: '✅ Yes, Delete',
|
||
data: { action: confirmAction, storeNumber, selected }
|
||
},
|
||
{
|
||
type: 'Action.Submit',
|
||
title: '❌ Cancel',
|
||
data: { action: 'cancel-remove', storeNumber }
|
||
}
|
||
]
|
||
};
|
||
|
||
await bot.say({
|
||
markdown: 'Confirm removal',
|
||
attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', content: confirmCard }]
|
||
}, roomId);
|
||
return; // don't send the main card yet
|
||
}
|
||
} else if (actionType === 'confirm-remove-bases' || actionType === 'confirm-remove-handsets') {
|
||
const isBase = actionType === 'confirm-remove-bases';
|
||
let rawSelected = inputs.selected;
|
||
const selected = Array.isArray(rawSelected) ? rawSelected : (rawSelected ? [rawSelected] : []);
|
||
const results = [];
|
||
for (const id of selected) {
|
||
try {
|
||
if (isBase) {
|
||
await removeDectBasestation(locationId, networkId, id);
|
||
results.push(`✅ base ${id}`);
|
||
} else {
|
||
await removeDectHandset(locationId, networkId, id);
|
||
results.push(`✅ handset ${id}`);
|
||
}
|
||
} catch (e) {
|
||
results.push(`❌ ${id}: ${e.message}`);
|
||
}
|
||
}
|
||
resultTitle = '✅ Remove Completed';
|
||
resultMsg = results.join('\n');
|
||
} else if (actionType === 'cancel-remove') {
|
||
resultTitle = 'ℹ️ Remove cancelled';
|
||
resultMsg = 'No changes made.';
|
||
} else {
|
||
resultTitle = 'ℹ️ Unknown action';
|
||
resultMsg = actionType;
|
||
}
|
||
|
||
// Re-fetch fresh status
|
||
const fresh = await getDectProvisioningStatus(storeNumber);
|
||
|
||
// Build fresh card and add banners (stale note + result)
|
||
const updatedCard = buildProvisioningCard(storeNumber, fresh.network, fresh.basestations, fresh.handsets);
|
||
updatedCard.body.unshift({
|
||
type: 'TextBlock',
|
||
text: `${resultTitle}\n${resultMsg}`,
|
||
weight: 'Bolder',
|
||
color: resultTitle.includes('✅') ? 'Good' : 'Attention'
|
||
});
|
||
updatedCard.body.unshift({
|
||
type: 'TextBlock',
|
||
text: staleNote,
|
||
wrap: true,
|
||
color: 'Attention'
|
||
});
|
||
|
||
await bot.say({
|
||
markdown: resultTitle,
|
||
attachments: [{
|
||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||
content: updatedCard
|
||
}]
|
||
}, roomId);
|
||
|
||
} catch (err) {
|
||
logger('phone:provision', `Action ${actionType} error for ${storeNumber}: ${err.message}`, 'error');
|
||
await bot.say('markdown', `❌ Action failed: ${err.message}. Previous card may be stale.`, roomId);
|
||
}
|
||
} |