Add /provisionDect + create DECT network in /finalizeStore
Some checks are pending
CI / verify (push) Waiting to run

New /provisionDect slash command manages DECT basestations and handsets
for a store via a single card: multi-select checkbox list doubles as
the display, MACs render as AA:BB:CC:DD:EE:FF, Add/Remove sit
side-by-side per section, and every removal goes through an explicit
confirm card. Handsets always auto-pair (no bind-to-basestation input)
so they roam.

/finalizeStore now idempotently creates the "Store XXXX" DECT network
(DBS-210) with the per-store default access code, so new stores are
DECT-ready the moment finalize completes. Location-scoped lookup
(findDectNetworkInLocation) handles both the finalize idempotency
check and the /provisionDect fallback for freshly-created empty
networks. Non-critical: a store can still go live if the DECT step
fails, and /provisionDect keeps a recovery "create network" prompt
for legacy stores.

Pure-logic helpers (generateDectAccessCode, MAC normalize/format/
display, dectNetworkName) are unit-tested via node:test.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-07 07:29:21 -04:00
parent b5610acc8e
commit 5dce057ce7
9 changed files with 1169 additions and 2 deletions

View file

@ -133,8 +133,17 @@ Registered in [src/commands](src/commands):
except the phone number, which has to be purchased separately in Control Hub. except the phone number, which has to be purchased separately in Control Hub.
- `/finalizeStore <storeNumber>` — cut-over for a previously staged store: - `/finalizeStore <storeNumber>` — cut-over for a previously staged store:
attaches the purchased phone number, sets caller ID, creates the attaches the purchased phone number, sets caller ID, creates the
auto-attendant, and finalizes user licensing. Preflights that the store `Store XXXX` DECT network (DBS-210) with the per-store default access
was actually staged first. code, creates the auto-attendant, and finalizes user licensing.
Preflights that the store was actually staged first, and is idempotent
on the DECT network (skips if one already exists).
- `/provisionDect <storeNumber>` — manage DECT phones for a store: card
for adding basestations by MAC and adding/removing handsets. Every
removal goes through an explicit confirmation card. New stores have
their DECT network created by `/finalizeStore`; this command still
offers a recovery "create network" prompt for legacy stores or if
finalize's DECT step was skipped. All Webex API — no remote agent
required.
- `/storeinfo <storeNumber>` — show current Webex info for the store user - `/storeinfo <storeNumber>` — show current Webex info for the store user
(`ae<5-digit>@ae.com`). (`ae<5-digit>@ae.com`).
- `/userinfo <email>` — show current Webex info for any user by email. - `/userinfo <email>` — show current Webex info for any user by email.

324
src/cards/dectCards.js Normal file
View file

@ -0,0 +1,324 @@
// Adaptive Cards for the /provisionDect command.
//
// Three related card builders live here because they form one UI flow:
// dectCreateNetworkCard - shown when the store has no DECT network yet
// dectManagementCard - main list + add + multi-select-remove card
// dectConfirmRemoveCard - destructive-op confirmation before delete
//
// All action strings are namespaced `dect-*` so attachmentActions.js can
// dispatch them without collision with the existing stage/finalize actions.
import { displayMac } from '../webex/dect.js';
const SCHEMA = 'http://adaptivecards.io/schemas/adaptive-card.json';
/**
* Prompt to create the "Store XXXX" DECT network. Shown when the store user
* has no DECT network attached yet. Includes the auto-generated 4-digit
* access code, which the operator can edit before confirming.
*/
export function dectCreateNetworkCard(
storeNumber,
{ locationId, locationName, suggestedAccessCode },
) {
return {
type: 'AdaptiveCard',
$schema: SCHEMA,
version: '1.3',
body: [
{
type: 'TextBlock',
text: `No DECT network exists for Store ${String(storeNumber).padStart(4, '0')}`,
weight: 'Bolder',
size: 'Medium',
wrap: true,
},
{
type: 'TextBlock',
text: `Create it in location **${locationName}**?`,
wrap: true,
},
{
type: 'TextBlock',
text:
'New stores get their DECT network created automatically by ' +
'`/finalizeStore`. Use this recovery form only for legacy ' +
'stores or if that step was skipped.',
wrap: true,
isSubtle: true,
size: 'Small',
},
{
type: 'Input.Text',
id: 'defaultAccessCode',
label: 'Default access code (4 digits)',
value: suggestedAccessCode,
regex: '^\\d{4}$',
errorMessage: 'Must be exactly 4 digits.',
},
{
type: 'TextBlock',
text: 'This code is shared by every handset that pairs with the network. Model is fixed to DBS-210.',
wrap: true,
isSubtle: true,
size: 'Small',
},
],
actions: [
{
type: 'Action.Submit',
title: 'Create network',
id: 'btnDectCreate',
data: { action: 'dect-create-network', storeNumber, locationId },
},
{
type: 'Action.Submit',
title: 'Cancel',
id: 'btnCancel',
data: { action: 'deleteCard' },
},
],
};
}
/**
* Full DECT management card: current bases + handsets, plus add / remove
* controls for both. Every action carries the storeNumber/locationId/
* networkId in its `data` so the dispatcher never has to re-resolve them.
*/
export function dectManagementCard(storeNumber, { network, basestations, handsets }, resultBanner) {
const padded = String(storeNumber).padStart(4, '0');
const dataCtx = {
storeNumber,
locationId: network.locationId,
networkId: network.id,
};
const basesSection = renderBases(basestations, dataCtx);
const handsetsSection = renderHandsets(handsets, dataCtx);
return {
type: 'AdaptiveCard',
$schema: SCHEMA,
version: '1.3',
body: [
{
type: 'TextBlock',
text: `DECT — Store ${padded}`,
weight: 'Bolder',
size: 'Large',
},
...(resultBanner ? [resultBannerBlock(resultBanner)] : []),
...basesSection,
...handsetsSection,
],
actions: [
{
type: 'Action.Submit',
title: 'Refresh',
id: 'btnDectRefresh',
data: { action: 'dect-refresh', storeNumber },
},
{
type: 'Action.Submit',
title: 'Close',
id: 'btnCancel',
data: { action: 'deleteCard' },
},
],
};
}
function renderBases(basestations, dataCtx) {
return [
{
type: 'TextBlock',
text: `Basestations (${basestations.length})`,
weight: 'Bolder',
size: 'Medium',
separator: true,
},
// Single list. Each row is a checkbox whose label carries the full
// display info. Checking + clicking Remove is the removal path; the
// list itself doubles as the read-only view for operators who just
// want to see what's there.
basestations.length === 0
? { type: 'TextBlock', text: '_None yet._', wrap: true, isSubtle: true }
: {
type: 'Input.ChoiceSet',
id: 'removeBases',
isMultiSelect: true,
style: 'expanded',
choices: basestations.map((b) => ({
title: `${displayMac(b.mac)} · ${b.status} · ${b.linesRegistered} line${b.linesRegistered === 1 ? '' : 's'}`,
value: b.id,
})),
},
{
type: 'Input.Text',
id: 'basesInput',
label: 'Add basestation MAC(s)',
placeholder: 'AA:BB:CC:DD:EE:FF, another-mac ...',
},
{
type: 'ActionSet',
actions: [
{
type: 'Action.Submit',
title: 'Add',
id: 'btnDectAddBases',
data: { action: 'dect-add-bases', ...dataCtx },
},
...(basestations.length > 0
? [
{
type: 'Action.Submit',
title: 'Remove checked',
id: 'btnDectRemoveBases',
style: 'destructive',
data: { action: 'dect-remove-bases', ...dataCtx },
},
]
: []),
],
},
];
}
function renderHandsets(handsets, dataCtx) {
return [
{
type: 'TextBlock',
text: `Handsets (${handsets.length})`,
weight: 'Bolder',
size: 'Medium',
separator: true,
},
handsets.length === 0
? { type: 'TextBlock', text: '_None yet._', wrap: true, isSubtle: true }
: {
type: 'Input.ChoiceSet',
id: 'removeHandsets',
isMultiSelect: true,
style: 'expanded',
choices: handsets.map((h) => {
const idx = h.index ? `#${h.index} ` : '';
return {
title: `${idx}${h.name} · ext ${h.extension ?? '—'}`,
value: h.id,
};
}),
},
{
type: 'Input.Text',
id: 'handsetAccessCode',
label: 'Access code (4 digits)',
regex: '^\\d{4}$',
errorMessage: 'Must be exactly 4 digits.',
},
{
type: 'Input.Text',
id: 'handsetDisplayName',
label: 'Display name (optional)',
placeholder: 'defaults to access code',
},
// Note: no "bind to basestation" input by design. Handsets always
// auto-pair against the network so they can roam between bases.
{
type: 'ActionSet',
actions: [
{
type: 'Action.Submit',
title: 'Add',
id: 'btnDectAddHandset',
data: { action: 'dect-add-handset', ...dataCtx },
},
...(handsets.length > 0
? [
{
type: 'Action.Submit',
title: 'Remove checked',
id: 'btnDectRemoveHandsets',
style: 'destructive',
data: { action: 'dect-remove-handsets', ...dataCtx },
},
]
: []),
],
},
];
}
/**
* Confirmation card before a destructive removal. `items` is an array of
* `{ id, label }` describing what will be deleted; `kind` is 'base' or
* 'handset' for wording; `confirmAction` is the action string the Yes
* button posts back (e.g. 'dect-confirm-remove-bases').
*/
export function dectConfirmRemoveCard({
storeNumber,
locationId,
networkId,
kind,
items,
confirmAction,
}) {
const noun = kind === 'base' ? 'basestation' : 'handset';
const plural = items.length === 1 ? noun : `${noun}s`;
return {
type: 'AdaptiveCard',
$schema: SCHEMA,
version: '1.3',
body: [
{
type: 'TextBlock',
text: `Confirm removal of ${items.length} ${plural}`,
weight: 'Bolder',
size: 'Large',
color: 'Attention',
},
{
type: 'TextBlock',
text: `Store ${String(storeNumber).padStart(4, '0')} — this cannot be undone.`,
wrap: true,
},
{
type: 'TextBlock',
text: items.map((i) => `- ${i.label}`).join('\n'),
wrap: true,
},
],
actions: [
{
type: 'Action.Submit',
title: `Yes, remove ${items.length}`,
id: 'btnDectConfirmRemove',
style: 'destructive',
data: {
action: confirmAction,
storeNumber,
locationId,
networkId,
ids: items.map((i) => i.id),
},
},
{
type: 'Action.Submit',
title: 'Cancel',
id: 'btnCancel',
data: { action: 'dect-refresh', storeNumber },
},
],
};
}
function resultBannerBlock({ text, tone = 'default' }) {
return {
type: 'TextBlock',
text,
wrap: true,
color: tone === 'error' ? 'Attention' : tone === 'good' ? 'Good' : 'Default',
weight: 'Bolder',
spacing: 'Medium',
};
}

View file

@ -2,6 +2,7 @@ import { logger } from '../logger.js';
import { stageStoreLocation } from '../flows/stageStore.js'; import { stageStoreLocation } from '../flows/stageStore.js';
import { finalizeStoreLocation } from '../flows/finalizeStore.js'; import { finalizeStoreLocation } from '../flows/finalizeStore.js';
import { getWebexDeviceDetail } from '../webex/devices.js'; import { getWebexDeviceDetail } from '../webex/devices.js';
import { handleDectAction } from './dectActions.js';
async function runFlow(bot, trigger, verb, fn) { async function runFlow(bot, trigger, verb, fn) {
const { storeInfo, userInfo } = trigger.attachmentAction.inputs; const { storeInfo, userInfo } = trigger.attachmentAction.inputs;
@ -34,6 +35,12 @@ export function register(framework) {
logger.debug('attachmentAction', JSON.stringify(trigger)); logger.debug('attachmentAction', JSON.stringify(trigger));
const action = trigger.attachmentAction.inputs.action; const action = trigger.attachmentAction.inputs.action;
// DECT flow has its own sub-dispatcher.
if (typeof action === 'string' && action.startsWith('dect-')) {
await handleDectAction(bot, trigger, action);
return;
}
switch (action) { switch (action) {
case 'stageStore': case 'stageStore':
await runFlow(bot, trigger, 'Staging', stageStoreLocation); await runFlow(bot, trigger, 'Staging', stageStoreLocation);

214
src/commands/dectActions.js Normal file
View file

@ -0,0 +1,214 @@
// Attachment-action handlers for the /provisionDect card flow.
//
// Every dect-* action posted by the DECT cards routes here. The dispatcher
// keeps a single shared shape: pull storeNumber/locationId/networkId out
// of `inputs`, do the work, then re-render the management card so the user
// sees the updated state without having to re-run /provisionDect.
//
// Errors are caught locally and reported inline in the refreshed card's
// result banner — a failed remove shouldn't take the whole session down.
import { logger } from '../logger.js';
import {
addDectBasestation,
addDectHandset,
createDectNetwork,
displayMac,
getDectProvisioningStatus,
removeDectBasestation,
removeDectHandset,
} from '../webex/dect.js';
import { dectConfirmRemoveCard, dectManagementCard } from '../cards/dectCards.js';
/**
* Dispatch a `dect-*` action. Returns true if handled, false if the action
* isn't one of ours (caller should keep switching).
*/
export async function handleDectAction(bot, trigger, action) {
const inputs = trigger.attachmentAction.inputs || {};
const messageId = trigger.attachmentAction.messageId;
const storeNumber = inputs.storeNumber;
try {
switch (action) {
case 'dect-create-network':
await doCreateNetwork(bot, inputs, messageId);
return true;
case 'dect-add-bases':
await doAddBases(bot, inputs, messageId);
return true;
case 'dect-add-handset':
await doAddHandset(bot, inputs, messageId);
return true;
case 'dect-remove-bases':
case 'dect-remove-handsets':
await doStageRemoval(bot, inputs, action, messageId);
return true;
case 'dect-confirm-remove-bases':
await doConfirmRemove(bot, inputs, 'base', messageId);
return true;
case 'dect-confirm-remove-handsets':
await doConfirmRemove(bot, inputs, 'handset', messageId);
return true;
case 'dect-refresh':
await refreshCard(bot, storeNumber, messageId);
return true;
default:
return false;
}
} catch (error) {
logger.error(`DECT action ${action} failed:`, error);
bot.say('markdown', `Error handling **${action}**:\n\`\`\`\n${error.message}\n\`\`\``);
return true;
}
}
async function doCreateNetwork(bot, inputs, messageId) {
const storeNumber = inputs.storeNumber;
const locationId = inputs.locationId;
const overrideCode = String(inputs.defaultAccessCode || '').trim();
if (overrideCode && !/^\d{4}$/.test(overrideCode)) {
bot.say('Access code must be exactly 4 digits.');
return;
}
await createDectNetwork(locationId, storeNumber, {
defaultAccessCode: overrideCode || undefined,
});
bot.censor(messageId);
await refreshCard(bot, storeNumber, null, {
text: `Created DECT network for Store ${padded(storeNumber)}.`,
tone: 'good',
});
}
async function doAddBases(bot, inputs, messageId) {
const { storeNumber, locationId, networkId } = inputs;
const macs = String(inputs.basesInput || '').trim();
if (!macs) {
bot.say('Enter one or more MAC addresses in the input before clicking Add.');
return;
}
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);
bot.censor(messageId);
const banner = {
text: `Bases: added ${added}, skipped ${skipped}${failed.length ? `, failed ${failed.length}` : ''}.`,
tone: failed.length ? 'error' : 'good',
};
if (failed.length) {
banner.text += '\n' + failed.map((f) => `- ${f.mac}: ${f.error}`).join('\n');
}
await refreshCard(bot, storeNumber, null, banner);
}
async function doAddHandset(bot, inputs, messageId) {
const { storeNumber, locationId, networkId } = inputs;
const accessCode = String(inputs.handsetAccessCode || '').trim();
const displayName = String(inputs.handsetDisplayName || '').trim() || undefined;
if (!/^\d{4}$/.test(accessCode)) {
bot.say('Access code must be exactly 4 digits.');
return;
}
// Handsets are always added without a baseStationId so they auto-pair
// and can roam between all bases in the network.
const res = await addDectHandset(locationId, networkId, { accessCode, displayName });
bot.censor(messageId);
const banner = res?.alreadyExists
? { text: `Handset with access code ${accessCode} already exists.`, tone: 'default' }
: { text: `Added handset ${accessCode}.`, tone: 'good' };
await refreshCard(bot, storeNumber, null, banner);
}
async function doStageRemoval(bot, inputs, action, messageId) {
const { storeNumber, locationId, networkId } = inputs;
const kind = action === 'dect-remove-bases' ? 'base' : 'handset';
const raw = kind === 'base' ? inputs.removeBases : inputs.removeHandsets;
const selected = Array.isArray(raw) ? raw : raw ? String(raw).split(',') : [];
if (selected.length === 0) {
bot.say(`Select at least one ${kind} using the checkboxes above before clicking Remove.`);
return;
}
// Enrich selection with friendly labels for the confirm card.
const status = await getDectProvisioningStatus(storeNumber);
const source = kind === 'base' ? status.basestations : status.handsets;
const items = selected.map((id) => {
const hit = source.find((x) => x.id === id);
if (!hit) return { id, label: id };
return kind === 'base'
? { id, label: `${displayMac(hit.mac)} (${hit.status})` }
: {
id,
label: `${hit.index ? `#${hit.index} ` : ''}${hit.name} (${hit.extension ?? '—'})`,
};
});
bot.censor(messageId);
bot.sendCard(
dectConfirmRemoveCard({
storeNumber,
locationId,
networkId,
kind,
items,
confirmAction:
kind === 'base' ? 'dect-confirm-remove-bases' : 'dect-confirm-remove-handsets',
}),
'Confirm removal in a client that supports Adaptive Cards',
);
}
async function doConfirmRemove(bot, inputs, kind, messageId) {
const { storeNumber, locationId, networkId } = inputs;
const ids = Array.isArray(inputs.ids) ? inputs.ids : [];
if (ids.length === 0) {
bot.say('Nothing selected to remove.');
return;
}
const remove = kind === 'base' ? removeDectBasestation : removeDectHandset;
const results = await Promise.allSettled(ids.map((id) => remove(locationId, networkId, id)));
const succeeded = results.filter((r) => r.status === 'fulfilled').length;
const failed = results
.map((r, i) => ({ r, id: ids[i] }))
.filter(({ r }) => r.status === 'rejected');
bot.censor(messageId);
const noun = kind === 'base' ? 'base' : 'handset';
let text = `Removed ${succeeded} ${noun}${succeeded === 1 ? '' : 's'}.`;
let tone = 'good';
if (failed.length) {
tone = 'error';
text += ` Failed ${failed.length}:\n${failed
.map(({ r, id }) => `- ${id}: ${r.reason?.message ?? r.reason}`)
.join('\n')}`;
}
await refreshCard(bot, storeNumber, null, { text, tone });
}
async function refreshCard(bot, storeNumber, messageIdToCensor, banner) {
if (messageIdToCensor) bot.censor(messageIdToCensor);
const status = await getDectProvisioningStatus(storeNumber);
if (!status.network) {
bot.say(
'markdown',
`DECT network for Store ${padded(storeNumber)} no longer exists. ` +
`Run \`/provisionDect ${storeNumber}\` to re-create it.`,
);
return;
}
bot.sendCard(dectManagementCard(storeNumber, status, banner), 'Please use another client');
}
function padded(storeNumber) {
return String(storeNumber).padStart(4, '0');
}

View file

@ -0,0 +1,63 @@
import { logger } from '../logger.js';
import { findWebexLocation } from '../webex/locations.js';
import {
dectNetworkName,
generateDectAccessCode,
getDectProvisioningStatus,
} from '../webex/dect.js';
import { dectCreateNetworkCard, dectManagementCard } from '../cards/dectCards.js';
import { parseStoreNumber } from './helpers.js';
// /provisionDect does NOT require the remote agent — every operation is
// against public Webex Calling APIs, which the bot reaches directly.
export function register(framework) {
framework.hears(
/\/provisiondect/i,
async (bot, trigger) => {
logger.info(`${trigger.person.displayName} ran the provisionDect command.`);
const storeNumber = parseStoreNumber(trigger);
if (!storeNumber) {
bot.say('Usage: `/provisionDect <storeNumber>` — e.g. `/provisionDect 499`');
return;
}
try {
const status = await getDectProvisioningStatus(storeNumber);
if (status.network) {
bot.sendCard(
dectManagementCard(storeNumber, status),
'Please use another client',
);
return;
}
// No network yet — need the store's Webex location to create in.
const matches = await findWebexLocation(dectNetworkName(storeNumber));
if (!matches.length) {
bot.say(
'markdown',
`No Webex location found for **${dectNetworkName(storeNumber)}**. ` +
`Run \`/stageStore ${storeNumber}\` first.`,
);
return;
}
const location = matches[0];
bot.sendCard(
dectCreateNetworkCard(storeNumber, {
locationId: location.id,
locationName: location.name,
suggestedAccessCode: generateDectAccessCode(storeNumber),
}),
'Please use another client',
);
} catch (error) {
logger.error('provisionDect command failed:', error);
bot.say(
'markdown',
`Error running /provisionDect:\n\`\`\`\n${error.message}\n\`\`\``,
);
}
},
'**/provisionDect** <storeNumber> - Add or remove DECT basestations and handsets for a store. New stores get their DECT network created by /finalizeStore; this command offers a recovery-create prompt if the network is missing.',
);
}

View file

@ -12,6 +12,12 @@ import {
updateUserExtension, updateUserExtension,
updateUserVoicemailSettings, updateUserVoicemailSettings,
} from '../webex/users.js'; } from '../webex/users.js';
import {
createDectNetwork,
dectNetworkName,
findDectNetworkInLocation,
generateDectAccessCode,
} from '../webex/dect.js';
import { CALLER_ID } from '../constants.js'; import { CALLER_ID } from '../constants.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { runStep } from './stepRunner.js'; import { runStep } from './stepRunner.js';
@ -42,6 +48,7 @@ export async function finalizeStoreLocation(bot, locationInfo, userInfo) {
await runStep(bot, 'Updated location Webex Calling Details', () => await runStep(bot, 'Updated location Webex Calling Details', () =>
updateLocationCallingIdentity(location, locationInfo.phoneNumber), updateLocationCallingIdentity(location, locationInfo.phoneNumber),
); );
await ensureDectNetwork(bot, location, locationInfo.storeNumber);
await runStep(bot, 'Updated user extension', () => await runStep(bot, 'Updated user extension', () =>
updateUserExtension(userInfo, locationInfo.extension), updateUserExtension(userInfo, locationInfo.extension),
); );
@ -78,3 +85,36 @@ export async function finalizeStoreLocation(bot, locationInfo, userInfo) {
bot.say('markdown', `**Finalize complete for ${locationInfo.name}.** Location is now live.`); bot.say('markdown', `**Finalize complete for ${locationInfo.name}.** Location is now live.`);
return location; return location;
} }
/**
* Idempotently ensure the store's DECT network exists in this location.
* Skipped-because-exists is a first-class outcome (shown as its own
* success blockquote) so operators can see at a glance whether finalize
* actually created the network or found an existing one from a prior
* /provisionDect run. Non-critical: a store can go live without DECT.
*/
async function ensureDectNetwork(bot, location, storeNumber) {
const netName = dectNetworkName(storeNumber);
try {
const existing = await findDectNetworkInLocation(location.id, storeNumber);
if (existing) {
bot.say(
'markdown',
`<blockquote class='success'>DECT network **${netName}** already exists — skipped.</blockquote>`,
);
return existing;
}
} catch (error) {
logger.warn(`DECT network pre-check failed for ${netName}: ${error.message}`);
}
// The default access code (the "DECT pin") is derived from the store
// number via generateDectAccessCode — 4-digit stores use their own
// digits, shorter stores prefix "8". Passing it explicitly is
// redundant (createDectNetwork defaults to the same rule) but makes
// the intent visible when reading the finalize flow top-to-bottom.
return runStep(bot, `Created DECT network ${netName}`, () =>
createDectNetwork(location.id, storeNumber, {
defaultAccessCode: generateDectAccessCode(storeNumber),
}),
);
}

View file

@ -6,6 +6,7 @@ import { isAccessTokenExpiring, refreshAccessToken } from './webex/auth.js';
import { startWebSocketServer, stopWebSocketServer } from './services/websocket.js'; import { startWebSocketServer, stopWebSocketServer } from './services/websocket.js';
import { register as registerStageStore } from './commands/stageStore.js'; import { register as registerStageStore } from './commands/stageStore.js';
import { register as registerFinalizeStore } from './commands/finalizeStore.js'; import { register as registerFinalizeStore } from './commands/finalizeStore.js';
import { register as registerProvisionDect } from './commands/provisionDect.js';
import { register as registerStoreInfo } from './commands/storeInfo.js'; import { register as registerStoreInfo } from './commands/storeInfo.js';
import { register as registerUserInfo } from './commands/userInfo.js'; import { register as registerUserInfo } from './commands/userInfo.js';
import { register as registerAttachmentActions } from './commands/attachmentActions.js'; import { register as registerAttachmentActions } from './commands/attachmentActions.js';
@ -22,6 +23,7 @@ framework.on('log', (msg) => {
registerStageStore(framework); registerStageStore(framework);
registerFinalizeStore(framework); registerFinalizeStore(framework);
registerProvisionDect(framework);
registerStoreInfo(framework); registerStoreInfo(framework);
registerUserInfo(framework); registerUserInfo(framework);
registerAttachmentActions(framework); registerAttachmentActions(framework);

406
src/webex/dect.js Normal file
View file

@ -0,0 +1,406 @@
// DECT provisioning helpers for the /provisionDect command.
//
// Ported from collabFinder's services/phoneService.js (bottom section:
// "DECT Provisioning helpers"), with two changes:
// - axios calls swapped for our existing webexJson (native fetch + auth)
// - added createDectNetwork(), which collabFinder didn't cover (they
// required the network to exist first)
//
// AE conventions (identical to collabFinder):
// - Store user email: ae<5-digit-padded>@ae.com
// - DECT network name: "Store <4-digit-padded>"
// - Access code: 4 digits. Stores with 4+ digits use their first 4;
// shorter stores prefix "8" (e.g. 347 -> 8347, 67 -> 8067).
//
// The DECT network access code is set once at network-create time as the
// default and applies to every handset that pairs against the network.
import { webexJson } from './client.js';
import { findWebexLocation } from './locations.js';
import { findWebexUser } from './users.js';
import { logger } from '../logger.js';
const DECT_MODEL = 'DMS Cisco DBS210';
// Pure helpers (exported so we can unit-test them without touching Webex)
/**
* AE-specific access code rule: 4-digit stores use their store number as-is,
* shorter stores prefix "8" and pad the rest to 3 digits.
* 500 -> "8500"
* 67 -> "8067"
* 499 -> "8499"
* 1234 -> "1234"
* 12345 -> "1234" (first 4)
*/
export function generateDectAccessCode(storeNumber) {
const digits = String(storeNumber).replace(/\D/g, '');
if (digits.length >= 4) return digits.slice(0, 4);
return '8' + digits.padStart(3, '0');
}
/**
* Normalize a MAC address to 12 uppercase hex chars (no separators).
* Returns null if it isn't 12 hex chars after stripping non-hex.
*/
export function normalizeMac(rawMac) {
if (rawMac === undefined || rawMac === null) return null;
const clean = String(rawMac)
.replace(/[^0-9a-fA-F]/g, '')
.toUpperCase();
return clean.length === 12 ? clean : null;
}
/** "AABBCCDDEEFF" -> "AA:BB:CC:DD:EE:FF" for API + display consistency. */
export function formatMac(cleanMac) {
return cleanMac.match(/.{1,2}/g).join(':');
}
/**
* Best-effort MAC display formatter for UI rendering. Accepts any of the
* shapes Webex returns (unseparated, colon-separated, hyphen-separated) and
* always produces AA:BB:CC:DD:EE:FF. Falls through to the raw value when
* the input isn't 12 hex chars, so it never throws on unexpected responses.
*/
export function displayMac(raw) {
const clean = normalizeMac(raw);
return clean ? formatMac(clean) : (raw ?? '—');
}
/** DECT network name for a given store number (matches Control Hub). */
export function dectNetworkName(storeNumber) {
return `Store ${String(storeNumber).padStart(4, '0')}`;
}
// Person + network lookup
async function findPersonIdForStore(storeNumber) {
const email = `ae${String(storeNumber).padStart(5, '0')}@ae.com`;
try {
const user = await findWebexUser(email);
return { personId: user.id, email };
} catch {
return { personId: null, email };
}
}
/**
* List the DECT networks a person has line memberships on. Returns [] when
* the person has no DECT lines (common for freshly staged stores).
*/
export async function getDectNetworksForPerson(personId) {
if (!personId) return [];
const data = await webexJson('GET', `/telephony/config/people/${personId}/dectNetworks`);
const networks = data?.dectNetworks ?? [];
return networks.map((net) => ({
id: net.id,
name: net.name || 'Unknown',
handsetsCount: net.numberOfHandsetsAssigned ?? 0,
locationName: net.location?.name ?? '—',
locationId: net.location?.id ?? null,
}));
}
/**
* Resolve the store's DECT network via person -> networks -> name match.
* Returns null if the store user doesn't exist, has no DECT lines, or the
* network name doesn't match "Store <4-digit>".
*
* NOTE: this misses newly-created empty networks (nobody has a line on
* them yet). Use findDectNetworkInLocation() for the idempotency check
* when creating; use getDectProvisioningStatus() from the UI (it falls
* back to the location lookup automatically).
*/
export async function findDectNetworkForStore(storeNumber) {
const { personId } = await findPersonIdForStore(storeNumber);
if (!personId) return null;
const networks = await getDectNetworksForPerson(personId);
const padded4 = String(storeNumber).padStart(4, '0');
const target = dectNetworkName(storeNumber).toLowerCase();
return (
networks.find((n) => {
const nm = (n.name ?? '').trim().toLowerCase();
return nm === target || nm.includes(padded4);
}) ?? null
);
}
/**
* Location-scoped DECT network lookup. Unlike findDectNetworkForStore
* this doesn't rely on the store user having lines on the network, so it
* safely finds freshly-created empty networks (e.g. right after finalize
* creates one and before any handsets are added). Returns the raw match
* or null.
*/
export async function findDectNetworkInLocation(locationId, storeNumber) {
if (!locationId) return null;
const data = await webexJson('GET', `/telephony/config/locations/${locationId}/dectNetworks`);
const items = data?.dectNetworks ?? data?.items ?? [];
const target = dectNetworkName(storeNumber).toLowerCase();
return items.find((n) => (n.name ?? '').trim().toLowerCase() === target) ?? null;
}
// Read-side helpers
export async function getDectBasestations(locationId, dectNetworkId) {
if (!locationId || !dectNetworkId) return [];
const data = await webexJson(
'GET',
`/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations`,
);
const items = data?.items ?? data?.baseStations ?? [];
return items.map((base) => ({
id: base.id,
mac: base.mac || base.macAddress || '—',
name: base.displayName || `Basestation ${base.mac || 'Unknown'}`,
status: base.status || 'unknown',
firmware: base.softwareVersion || '—',
linesRegistered: base.numberOfLinesRegistered ?? 0,
}));
}
export async function getDectHandsets(locationId, dectNetworkId) {
if (!locationId || !dectNetworkId) return [];
const data = await webexJson(
'GET',
`/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets`,
);
const items = data?.items ?? data?.handsets ?? [];
return items.map((handset) => ({
id: handset.id,
index: handset.index,
name: handset.defaultDisplayName || handset.displayName || `Handset ${handset.index ?? ''}`,
accessCode: handset.accessCode || null,
extension: handset.accessCode || handset.lines?.[0]?.esn || null,
lines: handset.lines || [],
}));
}
export async function getDectHandsetDetails(locationId, dectNetworkId, handsetId) {
if (!locationId || !dectNetworkId || !handsetId) return null;
const handset = await webexJson(
'GET',
`/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets/${handsetId}`,
);
return {
id: handset.id,
index: handset.index,
name: handset.defaultDisplayName || handset.displayName || `Handset ${handset.index ?? ''}`,
extension: handset.lines?.[0]?.extension ?? null,
baseStationId: handset.baseStationId ?? null,
};
}
/**
* One-shot status for the /provisionDect card: network + bases + handsets.
* Returns { network: null, ... } when the store has no DECT network yet.
*
* Deliberately does NOT enrich handsets via getDectHandsetDetails: that
* was N extra sequential API calls per card render just to derive the
* currently-bound base MAC, and the UI no longer displays that (handsets
* roam between bases by design). If we ever need per-handset detail
* again, call getDectHandsetDetails on demand for that one handset.
*/
export async function getDectProvisioningStatus(storeNumber) {
let network = await findDectNetworkForStore(storeNumber);
// Fallback: person-based lookup misses empty networks (no line
// memberships yet). Try resolving via the store's Webex location, which
// finalizeStore now creates. This keeps /provisionDect useful the
// moment a store is finalized, before any handsets exist.
if (!network) {
const target = dectNetworkName(storeNumber);
const [loc] = (await findWebexLocation(target)).filter((l) => l.name === target);
if (loc) {
const match = await findDectNetworkInLocation(loc.id, storeNumber);
if (match) {
network = {
id: match.id,
name: match.name || target,
handsetsCount: 0,
locationName: loc.name,
locationId: loc.id,
};
}
}
}
if (!network || !network.locationId || !network.id) {
return { network, basestations: [], handsets: [] };
}
const [basesRes, handsRes] = await Promise.allSettled([
getDectBasestations(network.locationId, network.id),
getDectHandsets(network.locationId, network.id),
]);
const basestations = basesRes.status === 'fulfilled' ? basesRes.value : [];
const handsets = handsRes.status === 'fulfilled' ? handsRes.value : [];
return { network, basestations, handsets };
}
// Mutating helpers
/**
* Create a multi-cell DECT network for this store's location. Uses the
* "Store <4-digit>" name convention. Access code defaults to the
* generateDectAccessCode(storeNumber) rule but can be overridden by the
* caller (the /provisionDect create card lets the operator edit it).
*
* Idempotency is the caller's responsibility Webex will 409 if a network
* with the same name already exists in this location.
*/
export async function createDectNetwork(locationId, storeNumber, { defaultAccessCode } = {}) {
if (!locationId) throw new Error('locationId required');
const code = defaultAccessCode ?? generateDectAccessCode(storeNumber);
if (!/^\d{4}$/.test(code)) throw new Error('defaultAccessCode must be exactly 4 digits');
const name = dectNetworkName(storeNumber);
const body = {
name,
displayName: name,
model: DECT_MODEL,
defaultAccessCodeEnabled: true,
defaultAccessCode: code,
};
const res = await webexJson(
'POST',
`/telephony/config/locations/${locationId}/dectNetworks`,
body,
);
logger.info(`Created DECT network ${name} (id=${res?.dectNetworkId})`);
return { id: res?.dectNetworkId, name, locationId };
}
/**
* Add one or more basestations. `macInput` may be a single MAC or several
* separated by commas/whitespace. Idempotent: MACs already on the network
* are skipped without an API call. Returns a per-MAC result array.
*/
export async function addDectBasestation(locationId, dectNetworkId, macInput) {
if (!locationId || !dectNetworkId || !macInput) {
throw new Error('locationId, dectNetworkId and mac(s) required');
}
const rawMacs = String(macInput)
.split(/[\s,]+/)
.map((m) => m.trim())
.filter(Boolean);
// Snapshot existing bases once; the loop is small enough that we don't
// need to re-fetch between iterations, and we already checked upstream
// that the network exists.
const existing = await getDectBasestations(locationId, dectNetworkId);
const existingCleanMacs = new Set(existing.map((b) => normalizeMac(b.mac)).filter(Boolean));
const results = [];
for (const raw of rawMacs) {
const clean = normalizeMac(raw);
if (!clean) {
results.push({ mac: raw, error: 'invalid MAC (need 12 hex chars)' });
continue;
}
const formatted = formatMac(clean);
if (existingCleanMacs.has(clean)) {
logger.debug(`Basestation ${formatted} already present — skipping`);
results.push({ mac: formatted, alreadyExists: true });
continue;
}
try {
const body = { mac: formatted, displayName: `Basestation ${formatted}` };
const res = await webexJson(
'POST',
`/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations`,
body,
);
logger.debug(`Added basestation ${formatted}`);
results.push({ mac: formatted, success: true, id: res?.id });
existingCleanMacs.add(clean);
} catch (err) {
logger.error(`Add basestation ${formatted} failed:`, err);
results.push({ mac: formatted, error: err.message });
}
}
return results;
}
/**
* Remove a basestation by id, or resolve a MAC to an id then remove.
*/
export async function removeDectBasestation(locationId, dectNetworkId, baseIdOrMac) {
if (!locationId || !dectNetworkId || !baseIdOrMac) {
throw new Error('locationId, networkId and base id/mac required');
}
let baseId = baseIdOrMac;
// MAC shape check: 12 hex chars (with any of `.` `-` `:` separators).
if (/^[0-9a-fA-F.:-]{12,17}$/.test(String(baseIdOrMac))) {
const clean = normalizeMac(baseIdOrMac);
if (clean) {
const bases = await getDectBasestations(locationId, dectNetworkId);
const match = bases.find((b) => normalizeMac(b.mac) === clean);
if (!match) throw new Error(`Basestation with MAC ${baseIdOrMac} not found`);
baseId = match.id;
}
}
await webexJson(
'DELETE',
`/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations/${baseId}`,
);
logger.debug(`Removed basestation ${baseId}`);
return { success: true, id: baseId };
}
/**
* Add a handset. `accessCode` must be exactly 4 digits (AE always uses the
* store's shared code). Idempotent: existing handsets with the same code
* are detected and returned as `{ alreadyExists: true }` without an API call.
* `baseStationId` is optional; when omitted, the handset auto-pairs against
* the network's default.
*/
export async function addDectHandset(
locationId,
dectNetworkId,
{ displayName, accessCode, baseStationId } = {},
) {
if (!locationId || !dectNetworkId || !accessCode) {
throw new Error('locationId, networkId and accessCode required');
}
const code = String(accessCode).trim();
if (!/^\d{4}$/.test(code)) throw new Error('Access code must be exactly 4 digits');
const existing = await getDectHandsets(locationId, dectNetworkId);
if (existing.some((h) => (h.extension || h.accessCode) === code)) {
logger.debug(`Handset with accessCode ${code} already exists`);
return { alreadyExists: true };
}
const body = {
defaultDisplayName: displayName || code,
accessCode: code,
lines: [{ index: 1, extension: code }],
};
if (baseStationId) body.baseStationId = baseStationId;
const res = await webexJson(
'POST',
`/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets`,
body,
);
logger.debug(`Added handset ${code}${baseStationId ? ` (base ${baseStationId})` : ''}`);
return res;
}
export async function removeDectHandset(locationId, dectNetworkId, handsetId) {
if (!locationId || !dectNetworkId || !handsetId) {
throw new Error('locationId, networkId and handsetId required');
}
await webexJson(
'DELETE',
`/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets/${handsetId}`,
);
logger.debug(`Removed handset ${handsetId}`);
return { success: true, id: handsetId };
}

102
test/dect.test.js Normal file
View file

@ -0,0 +1,102 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
dectNetworkName,
displayMac,
formatMac,
generateDectAccessCode,
normalizeMac,
} from '../src/webex/dect.js';
// AE's access code convention: 4-digit stores use their own digits; anything
// shorter prefixes '8' and pads the rest to 3. Ports collabFinder's rule
// verbatim so cross-project behaviour stays in sync.
describe('generateDectAccessCode', () => {
it('uses the store number verbatim for 4-digit stores', () => {
assert.equal(generateDectAccessCode(1234), '1234');
assert.equal(generateDectAccessCode('0499'), '0499');
});
it('truncates 5+ digit stores to the first 4', () => {
assert.equal(generateDectAccessCode(12345), '1234');
});
it("prefixes '8' and pads for shorter stores", () => {
assert.equal(generateDectAccessCode(347), '8347');
assert.equal(generateDectAccessCode(67), '8067');
assert.equal(generateDectAccessCode(1), '8001');
});
it('strips non-digit characters from the input', () => {
assert.equal(generateDectAccessCode('4-9-9'), '8499');
assert.equal(generateDectAccessCode('Store 1234'), '1234');
});
});
describe('normalizeMac', () => {
it('accepts colon-separated MACs', () => {
assert.equal(normalizeMac('AA:BB:CC:DD:EE:FF'), 'AABBCCDDEEFF');
});
it('accepts hyphen-separated MACs', () => {
assert.equal(normalizeMac('aa-bb-cc-dd-ee-ff'), 'AABBCCDDEEFF');
});
it('accepts unseparated MACs and case-normalizes', () => {
assert.equal(normalizeMac('aabbccddeeff'), 'AABBCCDDEEFF');
});
it('rejects too-short strings', () => {
assert.equal(normalizeMac('AA:BB:CC'), null);
});
it('rejects too-long strings', () => {
assert.equal(normalizeMac('AA:BB:CC:DD:EE:FF:11'), null);
});
it('rejects non-hex characters', () => {
// "Z" is not hex; after stripping we're left with 10 chars, not 12.
assert.equal(normalizeMac('AA:BB:CC:ZZ:EE:FF'), null);
});
it('returns null for missing input', () => {
assert.equal(normalizeMac(''), null);
assert.equal(normalizeMac(null), null);
assert.equal(normalizeMac(undefined), null);
});
});
describe('formatMac', () => {
it('inserts colons every two chars', () => {
assert.equal(formatMac('AABBCCDDEEFF'), 'AA:BB:CC:DD:EE:FF');
});
});
describe('displayMac', () => {
it('formats unseparated MACs (the Webex list-endpoint default)', () => {
assert.equal(displayMac('aabbccddeeff'), 'AA:BB:CC:DD:EE:FF');
});
it('re-formats already-separated MACs consistently', () => {
assert.equal(displayMac('aa-bb-cc-dd-ee-ff'), 'AA:BB:CC:DD:EE:FF');
});
it('passes unrecognised input through instead of throwing', () => {
assert.equal(displayMac('not-a-mac'), 'not-a-mac');
});
it('renders an em-dash for missing values', () => {
assert.equal(displayMac(null), '—');
assert.equal(displayMac(undefined), '—');
});
});
describe('dectNetworkName', () => {
it('produces the canonical "Store XXXX" name (4-digit padding)', () => {
assert.equal(dectNetworkName(499), 'Store 0499');
assert.equal(dectNetworkName(1), 'Store 0001');
assert.equal(dectNetworkName(1234), 'Store 1234');
assert.equal(dectNetworkName('67'), 'Store 0067');
});
});