Add /provisionDect + create DECT network in /finalizeStore
Some checks are pending
CI / verify (push) Waiting to run
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:
parent
b5610acc8e
commit
5dce057ce7
9 changed files with 1169 additions and 2 deletions
13
README.md
13
README.md
|
|
@ -133,8 +133,17 @@ Registered in [src/commands](src/commands):
|
|||
except the phone number, which has to be purchased separately in Control Hub.
|
||||
- `/finalizeStore <storeNumber>` — cut-over for a previously staged store:
|
||||
attaches the purchased phone number, sets caller ID, creates the
|
||||
auto-attendant, and finalizes user licensing. Preflights that the store
|
||||
was actually staged first.
|
||||
`Store XXXX` DECT network (DBS-210) with the per-store default access
|
||||
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
|
||||
(`ae<5-digit>@ae.com`).
|
||||
- `/userinfo <email>` — show current Webex info for any user by email.
|
||||
|
|
|
|||
324
src/cards/dectCards.js
Normal file
324
src/cards/dectCards.js
Normal 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',
|
||||
};
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { logger } from '../logger.js';
|
|||
import { stageStoreLocation } from '../flows/stageStore.js';
|
||||
import { finalizeStoreLocation } from '../flows/finalizeStore.js';
|
||||
import { getWebexDeviceDetail } from '../webex/devices.js';
|
||||
import { handleDectAction } from './dectActions.js';
|
||||
|
||||
async function runFlow(bot, trigger, verb, fn) {
|
||||
const { storeInfo, userInfo } = trigger.attachmentAction.inputs;
|
||||
|
|
@ -34,6 +35,12 @@ export function register(framework) {
|
|||
logger.debug('attachmentAction', JSON.stringify(trigger));
|
||||
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) {
|
||||
case 'stageStore':
|
||||
await runFlow(bot, trigger, 'Staging', stageStoreLocation);
|
||||
|
|
|
|||
214
src/commands/dectActions.js
Normal file
214
src/commands/dectActions.js
Normal 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');
|
||||
}
|
||||
63
src/commands/provisionDect.js
Normal file
63
src/commands/provisionDect.js
Normal 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.',
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,12 @@ import {
|
|||
updateUserExtension,
|
||||
updateUserVoicemailSettings,
|
||||
} from '../webex/users.js';
|
||||
import {
|
||||
createDectNetwork,
|
||||
dectNetworkName,
|
||||
findDectNetworkInLocation,
|
||||
generateDectAccessCode,
|
||||
} from '../webex/dect.js';
|
||||
import { CALLER_ID } from '../constants.js';
|
||||
import { logger } from '../logger.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', () =>
|
||||
updateLocationCallingIdentity(location, locationInfo.phoneNumber),
|
||||
);
|
||||
await ensureDectNetwork(bot, location, locationInfo.storeNumber);
|
||||
await runStep(bot, 'Updated user 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.`);
|
||||
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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { isAccessTokenExpiring, refreshAccessToken } from './webex/auth.js';
|
|||
import { startWebSocketServer, stopWebSocketServer } from './services/websocket.js';
|
||||
import { register as registerStageStore } from './commands/stageStore.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 registerUserInfo } from './commands/userInfo.js';
|
||||
import { register as registerAttachmentActions } from './commands/attachmentActions.js';
|
||||
|
|
@ -22,6 +23,7 @@ framework.on('log', (msg) => {
|
|||
|
||||
registerStageStore(framework);
|
||||
registerFinalizeStore(framework);
|
||||
registerProvisionDect(framework);
|
||||
registerStoreInfo(framework);
|
||||
registerUserInfo(framework);
|
||||
registerAttachmentActions(framework);
|
||||
|
|
|
|||
406
src/webex/dect.js
Normal file
406
src/webex/dect.js
Normal 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
102
test/dect.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue