Add /provisionPhone for wired desk phones (7841/7821)
Some checks failed
CI / verify (push) Has been cancelled

New /provisionPhone <storeNumber> command manages the store user's
wired desk phones via the same card pattern as /provisionDect:
multi-select checkbox list that doubles as the display, MACs render
as AA:BB:CC:DD:EE:FF, model dropdown for add (defaults to 7841),
side-by-side Add / Remove-checked actions, and every removal goes
through an explicit confirm card. Adds are idempotent (MACs already
registered are skipped) and bulk MAC input is supported.

DECT handsets are filtered out of the display so /provisionPhone and
/provisionDect coexist cleanly on the same store user without
overlapping responsibilities.

Refactors:
- Extract MAC helpers (normalize/format/display) to src/utils/mac.js
  so both DECT and wired-phone flows share one implementation. dect.js
  re-exports for backward compat with existing consumers.
- Add parseEmailArg helper; migrate /userInfo to use it.

Tests: pure-logic coverage for WIRED_PHONE_MODELS,
isSupportedWiredModel, filterWiredPhones (DECT/model filter), and
parseEmailArg.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-07 12:39:06 -04:00
parent 5dce057ce7
commit 6b585bf33f
15 changed files with 786 additions and 107 deletions

View file

@ -144,6 +144,14 @@ Registered in [src/commands](src/commands):
offers a recovery "create network" prompt for legacy stores or if offers a recovery "create network" prompt for legacy stores or if
finalize's DECT step was skipped. All Webex API — no remote agent finalize's DECT step was skipped. All Webex API — no remote agent
required. required.
- `/provisionPhone <storeNumber>` — manage wired desk phones (Cisco 7841
/ 7821) owned by the store user (resolved via `ae<5-digit>@ae.com`).
The card shows the store user's current wired phones (DECT handsets
are filtered out — those live in `/provisionDect`), with an add form
(MAC + model) and a checkbox list for removal. Adds are idempotent
(MACs already registered are skipped), and every removal goes through
an explicit confirmation card. 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.

196
src/cards/phoneCards.js Normal file
View file

@ -0,0 +1,196 @@
// Adaptive Cards for the /provisionPhone command.
//
// Two builders live here for one UI flow:
// phoneManagementCard - list + add + multi-select-remove for wired phones
// phoneConfirmRemoveCard - destructive-op confirmation before delete
//
// All action strings are namespaced `phone-*` so attachmentActions.js
// can dispatch them without colliding with stage/finalize/DECT actions.
import { displayMac } from '../utils/mac.js';
import { WIRED_PHONE_MODELS } from '../webex/phones.js';
const SCHEMA = 'http://adaptivecards.io/schemas/adaptive-card.json';
/**
* Main management card: shows the store user's current wired phones
* (checkbox list doubles as read-only display), plus an add form and
* side-by-side Add / Remove-checked actions. Optional `resultBanner`
* is rendered inline at the top after a mutation so operators see
* whether the previous submission succeeded or failed.
*
* Header shows the store number (matches /provisionDect) with the
* resolved user's displayName + email as a subtle subtitle so
* operators can eyeball that the right person was matched.
*/
export function phoneManagementCard(storeNumber, { user, phones }, resultBanner) {
const padded = String(storeNumber).padStart(4, '0');
const dataCtx = { storeNumber, personId: user.id };
return {
type: 'AdaptiveCard',
$schema: SCHEMA,
version: '1.3',
body: [
{
type: 'TextBlock',
text: `Phones — Store ${padded}`,
weight: 'Bolder',
size: 'Large',
},
{
type: 'TextBlock',
text: `${user.displayName ?? '—'} · \`${user.emails?.[0] ?? ''}\``,
wrap: true,
spacing: 'Small',
isSubtle: true,
},
...(resultBanner ? [resultBannerBlock(resultBanner)] : []),
...renderPhones(phones, dataCtx),
],
actions: [
{
type: 'Action.Submit',
title: 'Refresh',
id: 'btnPhoneRefresh',
data: { action: 'phone-refresh', storeNumber },
},
{
type: 'Action.Submit',
title: 'Close',
id: 'btnCancel',
data: { action: 'deleteCard' },
},
],
};
}
function renderPhones(phones, dataCtx) {
return [
{
type: 'TextBlock',
text: `Wired phones (${phones.length})`,
weight: 'Bolder',
size: 'Medium',
separator: true,
},
// One list. Each row is a checkbox whose label carries the full
// display info; the list doubles as the read-only view for
// operators who just want to see what's registered.
phones.length === 0
? { type: 'TextBlock', text: '_None yet._', wrap: true, isSubtle: true }
: {
type: 'Input.ChoiceSet',
id: 'removePhones',
isMultiSelect: true,
style: 'expanded',
choices: phones.map((p) => ({
title: `${p.model} · ${displayMac(p.mac)} · ${p.connectionStatus}`,
value: p.id,
})),
},
{
type: 'Input.ChoiceSet',
id: 'phoneModel',
label: 'Model',
value: WIRED_PHONE_MODELS[0].value,
choices: WIRED_PHONE_MODELS.map((m) => ({ title: m.label, value: m.value })),
},
{
type: 'Input.Text',
id: 'phoneMacs',
label: 'Add phone MAC(s)',
placeholder: 'AA:BB:CC:DD:EE:FF, another-mac ...',
},
{
type: 'ActionSet',
actions: [
{
type: 'Action.Submit',
title: 'Add',
id: 'btnPhoneAdd',
data: { action: 'phone-add', ...dataCtx },
},
...(phones.length > 0
? [
{
type: 'Action.Submit',
title: 'Remove checked',
id: 'btnPhoneRemove',
style: 'destructive',
data: { action: 'phone-remove', ...dataCtx },
},
]
: []),
],
},
];
}
/**
* Confirmation card before a destructive removal. `items` is an array of
* `{ id, label }` describing what will be deleted; `confirmAction` is
* the action string the Yes button posts back
* (e.g. 'phone-confirm-remove').
*/
export function phoneConfirmRemoveCard({ storeNumber, personId, items, confirmAction }) {
const plural = items.length === 1 ? 'phone' : 'phones';
return {
type: 'AdaptiveCard',
$schema: SCHEMA,
version: '1.3',
body: [
{
type: 'TextBlock',
text: `Remove ${items.length} ${plural}?`,
weight: 'Bolder',
size: 'Medium',
},
{
type: 'TextBlock',
text: items.map((i) => `- ${i.label}`).join('\n'),
wrap: true,
},
{
type: 'TextBlock',
text: 'This unregisters the device from the user in Webex Control Hub. The physical phone will drop registration until re-added.',
wrap: true,
isSubtle: true,
size: 'Small',
},
],
actions: [
{
type: 'Action.Submit',
title: 'Yes, remove',
id: 'btnPhoneConfirm',
style: 'destructive',
data: {
action: confirmAction,
storeNumber,
personId,
ids: items.map((i) => i.id),
},
},
{
type: 'Action.Submit',
title: 'Cancel',
id: 'btnCancel',
data: { action: 'deleteCard' },
},
],
};
}
// Match dectCards.resultBannerBlock so the two flows use identical
// visual language for post-mutation feedback.
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

@ -3,6 +3,7 @@ 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'; import { handleDectAction } from './dectActions.js';
import { handlePhoneAction } from './phoneActions.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;
@ -35,11 +36,16 @@ 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. // DECT and wired-phone flows each have their own sub-dispatcher
// so this file stays focused on the store lifecycle actions.
if (typeof action === 'string' && action.startsWith('dect-')) { if (typeof action === 'string' && action.startsWith('dect-')) {
await handleDectAction(bot, trigger, action); await handleDectAction(bot, trigger, action);
return; return;
} }
if (typeof action === 'string' && action.startsWith('phone-')) {
await handlePhoneAction(bot, trigger, action);
return;
}
switch (action) { switch (action) {
case 'stageStore': case 'stageStore':

View file

@ -37,6 +37,25 @@ export function parseStoreNumber(trigger) {
return raw; return raw;
} }
// Loose email format check — anything with `local@domain.tld` shape.
// Intent is to catch obvious typos early rather than to enforce full
// RFC 5322 (Webex will reject bad addresses anyway).
export const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Parse and validate an email argument. Returns the trimmed lowercase
* email when it looks like an email, `null` when missing/malformed.
* Callers should show a usage message on null. Lowercasing matches
* Webex's own normalization of email addresses.
*/
export function parseEmailArg(trigger) {
const raw = parseStoreArg(trigger);
if (!raw) return null;
const trimmed = raw.trim();
if (!EMAIL_SHAPE.test(trimmed)) return null;
return trimmed.toLowerCase();
}
/** /**
* Pull the first argument out of a webex-node-bot-framework trigger. * Pull the first argument out of a webex-node-bot-framework trigger.
* *

View file

@ -0,0 +1,157 @@
// Attachment-action handlers for the /provisionPhone card flow.
//
// Every phone-* action posted by the phone cards routes here. Mirrors
// dectActions.js: pull storeNumber/personId out of `inputs`, do the
// work, then re-render the management card so operators see the
// updated state without having to re-run /provisionPhone.
//
// Errors are caught locally and surfaced in the refreshed card's
// result banner — a failed remove shouldn't take the whole session
// down.
import { logger } from '../logger.js';
import { addWiredPhone, getWiredPhoneStatus, removeWiredPhone } from '../webex/phones.js';
import { displayMac } from '../utils/mac.js';
import { phoneConfirmRemoveCard, phoneManagementCard } from '../cards/phoneCards.js';
import { storeEmail } from './helpers.js';
/**
* Dispatch a `phone-*` action. Returns true if handled, false if the
* action isn't one of ours (caller should keep switching).
*/
export async function handlePhoneAction(bot, trigger, action) {
const inputs = trigger.attachmentAction.inputs || {};
const messageId = trigger.attachmentAction.messageId;
const storeNumber = inputs.storeNumber;
try {
switch (action) {
case 'phone-add':
await doAddPhone(bot, inputs, messageId);
return true;
case 'phone-remove':
await doStageRemoval(bot, inputs, messageId);
return true;
case 'phone-confirm-remove':
await doConfirmRemove(bot, inputs, messageId);
return true;
case 'phone-refresh':
await refreshCard(bot, storeNumber, messageId);
return true;
default:
return false;
}
} catch (error) {
logger.error(`Phone action ${action} failed:`, error);
bot.say('markdown', `Error handling **${action}**:\n\`\`\`\n${error.message}\n\`\`\``);
return true;
}
}
async function doAddPhone(bot, inputs, messageId) {
const { storeNumber, personId } = inputs;
const model = String(inputs.phoneModel || '').trim();
const macs = String(inputs.phoneMacs || '').trim();
if (!model) {
bot.say('Select a phone model before clicking Add.');
return;
}
if (!macs) {
bot.say('Enter one or more MAC addresses in the input before clicking Add.');
return;
}
const results = await addWiredPhone(personId, model, 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: `${model}: 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 doStageRemoval(bot, inputs, messageId) {
const { storeNumber, personId } = inputs;
// Adaptive Cards return multi-select ChoiceSets as either an array
// or a comma-separated string depending on client; normalize both.
const raw = inputs.removePhones;
const selected = Array.isArray(raw) ? raw : raw ? String(raw).split(',') : [];
if (selected.length === 0) {
bot.say('Select at least one phone using the checkboxes above before clicking Remove.');
return;
}
// Enrich with friendly labels for the confirm card.
const { phones } = await getWiredPhoneStatus(storeEmail(storeNumber));
const items = selected.map((id) => {
const hit = phones.find((p) => p.id === id);
if (!hit) return { id, label: id };
return { id, label: `${hit.model} · ${displayMac(hit.mac)}` };
});
bot.censor(messageId);
bot.sendCard(
phoneConfirmRemoveCard({
storeNumber,
personId,
items,
confirmAction: 'phone-confirm-remove',
}),
'Confirm removal in a client that supports Adaptive Cards',
);
}
async function doConfirmRemove(bot, inputs, messageId) {
const { storeNumber } = inputs;
const ids = Array.isArray(inputs.ids) ? inputs.ids : [];
if (ids.length === 0) {
bot.say('Nothing selected to remove.');
return;
}
const results = await Promise.allSettled(ids.map((id) => removeWiredPhone(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);
let text = `Removed ${succeeded} phone${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);
if (!storeNumber) {
bot.say('Cannot refresh phone card: no store number in card context.');
return;
}
try {
const status = await getWiredPhoneStatus(storeEmail(storeNumber));
bot.sendCard(phoneManagementCard(storeNumber, status, banner), 'Please use another client');
} catch (error) {
// Refresh should never take the whole session down. If the
// store user resolution fails (e.g. account deleted mid-session)
// give the operator a clean explanation instead of a stack trace.
logger.error(`Phone refresh failed for store ${storeNumber}:`, error);
bot.say(
'markdown',
`Could not refresh phones for store ${storeNumber} (\`${storeEmail(storeNumber)}\`):\n\`\`\`\n${error.message}\n\`\`\``,
);
}
}

View file

@ -0,0 +1,35 @@
import { logger } from '../logger.js';
import { getWiredPhoneStatus } from '../webex/phones.js';
import { phoneManagementCard } from '../cards/phoneCards.js';
import { parseStoreNumber, storeEmail } from './helpers.js';
// /provisionPhone is Webex-Calling-only — no remote agent required.
// Takes a store number and resolves to the store user via the AE
// convention `ae<5-digit>@ae.com` (matches /stageStore, /finalizeStore,
// /provisionDect). The card then lets operators add or remove the
// store user's wired desk phones (Cisco 7841 / 7821).
export function register(framework) {
framework.hears(
/\/provisionPhone/i,
async (bot, trigger) => {
logger.info(`${trigger.person.displayName} ran the provisionPhone command.`);
const storeNumber = parseStoreNumber(trigger);
if (!storeNumber) {
bot.say('Usage: `/provisionPhone <storeNumber>` — e.g. `/provisionPhone 499`');
return;
}
try {
const status = await getWiredPhoneStatus(storeEmail(storeNumber));
bot.sendCard(phoneManagementCard(storeNumber, status), 'Please use another client');
} catch (error) {
logger.error('provisionPhone command failed:', error);
bot.say(
'markdown',
`Error running /provisionPhone:\n\`\`\`\n${error.message}\n\`\`\``,
);
}
},
'**/provisionPhone** <storeNumber> - Add or remove wired desk phones (Cisco 7841/7821) owned by the store user. Every removal is confirmed on a second card.',
);
}

View file

@ -1,24 +1,18 @@
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { buildUserInfoCard } from '../cards/userInfoCard.js'; import { buildUserInfoCard } from '../cards/userInfoCard.js';
import { parseStoreArg } from './helpers.js'; import { parseEmailArg } from './helpers.js';
// Loose email format check — anything with `local@domain` shape. Intent
// is to catch obvious typos like `/userInfo joe` early rather than to
// enforce full RFC 5322 (Webex will reject bad addresses anyway).
const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function register(framework) { export function register(framework) {
framework.hears( framework.hears(
/\/userInfo/i, /\/userInfo/i,
async (bot, trigger) => { async (bot, trigger) => {
logger.info(`${trigger.person.displayName} ran the userInfo command.`); logger.info(`${trigger.person.displayName} ran the userInfo command.`);
const email = parseStoreArg(trigger); const email = parseEmailArg(trigger);
if (!email) { if (!email) {
bot.say('Usage: `/userInfo <email>` — e.g. `/userInfo mcqueenj@ae.com`'); bot.say(
return; 'Usage: `/userInfo <email>` — e.g. `/userInfo mcqueenj@ae.com`. ' +
} 'The argument must look like an email (`local@domain.tld`).',
if (!EMAIL_SHAPE.test(email)) { );
bot.say(`\`${email}\` doesn't look like an email address.`);
return; return;
} }
try { try {

View file

@ -7,6 +7,7 @@ import { startWebSocketServer, stopWebSocketServer } from './services/websocket.
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 registerProvisionDect } from './commands/provisionDect.js';
import { register as registerProvisionPhone } from './commands/provisionPhone.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';
@ -24,6 +25,7 @@ framework.on('log', (msg) => {
registerStageStore(framework); registerStageStore(framework);
registerFinalizeStore(framework); registerFinalizeStore(framework);
registerProvisionDect(framework); registerProvisionDect(framework);
registerProvisionPhone(framework);
registerStoreInfo(framework); registerStoreInfo(framework);
registerUserInfo(framework); registerUserInfo(framework);
registerAttachmentActions(framework); registerAttachmentActions(framework);

34
src/utils/mac.js Normal file
View file

@ -0,0 +1,34 @@
// MAC-address helpers shared by DECT and wired-phone flows.
//
// Kept minimal and dependency-free so it can be reused wherever a Webex
// device MAC needs to be parsed, sent to an API, or shown in a card.
/**
* Normalize a MAC to 12 uppercase hex chars (no separators). Accepts any
* of the shapes Webex returns (colon-, hyphen-, or dot-separated, or
* unseparated). Returns null when the input isn't 12 hex chars after
* stripping non-hex callers should treat null as "invalid MAC".
*/
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;
}
/** Insert colons every two chars: "AABBCCDDEEFF" -> "AA:BB:CC:DD:EE:FF". */
export function formatMac(cleanMac) {
return cleanMac.match(/.{1,2}/g).join(':');
}
/**
* Best-effort MAC display formatter for UI rendering. Accepts any shape
* and always produces AA:BB:CC:DD:EE:FF. Falls through to the raw value
* when the input isn't 12 hex chars, and returns "—" when missing, so
* cards never throw on unexpected API responses.
*/
export function displayMac(raw) {
const clean = normalizeMac(raw);
return clean ? formatMac(clean) : (raw ?? '—');
}

View file

@ -19,6 +19,12 @@ import { webexJson } from './client.js';
import { findWebexLocation } from './locations.js'; import { findWebexLocation } from './locations.js';
import { findWebexUser } from './users.js'; import { findWebexUser } from './users.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { formatMac, normalizeMac } from '../utils/mac.js';
// Re-exported for existing callers (dectCards, dectActions, tests) so
// they don't need import churn now that the helpers live in utils/mac.js.
// New non-DECT code should import from '../utils/mac.js' directly.
export { displayMac, formatMac, normalizeMac } from '../utils/mac.js';
const DECT_MODEL = 'DMS Cisco DBS210'; const DECT_MODEL = 'DMS Cisco DBS210';
@ -39,34 +45,6 @@ export function generateDectAccessCode(storeNumber) {
return '8' + digits.padStart(3, '0'); 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). */ /** DECT network name for a given store number (matches Control Hub). */
export function dectNetworkName(storeNumber) { export function dectNetworkName(storeNumber) {
return `Store ${String(storeNumber).padStart(4, '0')}`; return `Store ${String(storeNumber).padStart(4, '0')}`;

145
src/webex/phones.js Normal file
View file

@ -0,0 +1,145 @@
// Webex Calling helpers for wired desk phones (Cisco 78xx MPP).
//
// Mirrors the shape of src/webex/dect.js so /provisionPhone can follow
// the same "one card lists + adds + removes" pattern as /provisionDect.
// Uses:
// POST /devices add a phone owned by a person
// DELETE /devices/{deviceId} remove any device
// GET /devices?personId={id} list devices owned by a person
// (already wrapped by webex/users.findWebexUserPhones)
import { webexJson } from './client.js';
import { findWebexUser, findWebexUserPhones } from './users.js';
import { logger } from '../logger.js';
import { formatMac, normalizeMac } from '../utils/mac.js';
/**
* Supported wired phone models for /provisionPhone. The `value` field is
* sent verbatim to Webex's `POST /devices { model }` and must match
* exactly what Control Hub uses; add rows here as AE rolls out new
* hardware. Ordering matters: the first entry is the default in the UI.
*/
export const WIRED_PHONE_MODELS = [
{ label: 'Cisco 7841', value: 'Cisco 7841' },
{ label: 'Cisco 7821', value: 'Cisco 7821' },
];
export function isSupportedWiredModel(model) {
return WIRED_PHONE_MODELS.some((m) => m.value === model);
}
// Anything Webex returns whose product/type contains one of these markers
// is treated as a DECT device and filtered out of /provisionPhone views
// (DECT lives in its own /provisionDect card). Kept as a case-insensitive
// regex so future DBS-2xx / DBS-1xx variants also match without a code
// change.
const DECT_MARKERS = /dect|dbs-?\d{3}/i;
/**
* Reduce a raw /devices response to just the wired phones. Drops DECT
* handsets/basestations, keeps everything else (78xx MPPs, ATAs, etc.).
* Exported for unit-testing the filter in isolation.
*/
export function filterWiredPhones(items = []) {
return items.filter((d) => {
const model = String(d.product || d.model || '').trim();
if (!model) return false;
if (DECT_MARKERS.test(model)) return false;
return true;
});
}
/**
* List the wired phones registered to a Webex person, normalized to a
* card-friendly shape.
*/
export async function listWiredPhonesForUser(personId) {
if (!personId) return [];
const res = await findWebexUserPhones(personId);
const items = res?.items ?? [];
return filterWiredPhones(items).map((d) => ({
id: d.id,
mac: d.mac || '',
model: d.product || d.model || 'unknown',
displayName:
d.displayName || `${d.product || d.model || 'Phone'}${d.mac ? ` - ${d.mac}` : ''}`,
connectionStatus: d.connectionStatus || 'unknown',
}));
}
/**
* One-shot status for the /provisionPhone card: user + their wired
* phones. Throws if the email doesn't resolve to exactly one Webex user
* so the command handler can surface a clean error message.
*/
export async function getWiredPhoneStatus(email) {
const user = await findWebexUser(email);
const phones = await listWiredPhonesForUser(user.id);
return { user, phones };
}
/**
* Add one or more wired phones owned by `personId`. `macInput` may be a
* single MAC or several separated by commas/whitespace. Idempotent:
* MACs already registered to the user are skipped without an API call
* (Webex would 409 otherwise). Returns a per-MAC result array shaped
* like addDectBasestation for consistent action-handler code.
*/
export async function addWiredPhone(personId, model, macInput) {
if (!personId) throw new Error('personId required');
if (!model) throw new Error('model required');
if (!macInput) throw new Error('mac(s) required');
if (!isSupportedWiredModel(model)) {
const supported = WIRED_PHONE_MODELS.map((m) => m.value).join(', ');
throw new Error(`Unsupported model "${model}". Supported: ${supported}`);
}
const rawMacs = String(macInput)
.split(/[\s,]+/)
.map((m) => m.trim())
.filter(Boolean);
// Snapshot existing phones once. Small loop, no need to re-fetch
// between iterations; upstream caller has already validated the
// person exists.
const existing = await listWiredPhonesForUser(personId);
const existingCleanMacs = new Set(existing.map((p) => normalizeMac(p.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(`Wired phone ${formatted} already assigned — skipping`);
results.push({ mac: formatted, model, alreadyExists: true });
continue;
}
try {
const body = { mac: formatted, model, personId };
const res = await webexJson('POST', '/devices', body);
logger.debug(`Added ${model} ${formatted} to person ${personId}`);
results.push({ mac: formatted, model, success: true, id: res?.id });
existingCleanMacs.add(clean);
} catch (err) {
logger.error(`Add wired phone ${formatted} (${model}) failed:`, err);
results.push({ mac: formatted, model, error: err.message });
}
}
return results;
}
/**
* Remove a wired phone by device id. Symmetric with removeDectBasestation
* so the action handler can call this by id straight out of the checkbox
* ChoiceSet value.
*/
export async function removeWiredPhone(deviceId) {
if (!deviceId) throw new Error('deviceId required');
await webexJson('DELETE', `/devices/${encodeURIComponent(deviceId)}`);
logger.debug(`Removed device ${deviceId}`);
return { success: true, id: deviceId };
}

View file

@ -1,13 +1,7 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import { dectNetworkName, generateDectAccessCode } from '../src/webex/dect.js';
dectNetworkName,
displayMac,
formatMac,
generateDectAccessCode,
normalizeMac,
} from '../src/webex/dect.js';
// AE's access code convention: 4-digit stores use their own digits; anything // 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 // shorter prefixes '8' and pads the rest to 3. Ports collabFinder's rule
@ -34,64 +28,6 @@ describe('generateDectAccessCode', () => {
}); });
}); });
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', () => { describe('dectNetworkName', () => {
it('produces the canonical "Store XXXX" name (4-digit padding)', () => { it('produces the canonical "Store XXXX" name (4-digit padding)', () => {
assert.equal(dectNetworkName(499), 'Store 0499'); assert.equal(dectNetworkName(499), 'Store 0499');

View file

@ -1,7 +1,12 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseStoreArg, parseStoreNumber, storeEmail } from '../src/commands/helpers.js'; import {
parseEmailArg,
parseStoreArg,
parseStoreNumber,
storeEmail,
} from '../src/commands/helpers.js';
// parseStoreArg regressed once already: the framework's trigger.prompt is // parseStoreArg regressed once already: the framework's trigger.prompt is
// everything AFTER the matched command (e.g. " 792" for "/storeInfo 792"), // everything AFTER the matched command (e.g. " 792" for "/storeInfo 792"),
@ -72,3 +77,22 @@ describe('storeEmail', () => {
assert.equal(storeEmail('42'), 'ae00042@ae.com'); assert.equal(storeEmail('42'), 'ae00042@ae.com');
}); });
}); });
describe('parseEmailArg', () => {
it('returns a lowercased email when the shape looks valid', () => {
assert.equal(parseEmailArg({ prompt: ' McQueenJ@AE.com' }), 'mcqueenj@ae.com');
assert.equal(parseEmailArg({ prompt: ' ae00499@ae.com' }), 'ae00499@ae.com');
});
it('rejects non-email input', () => {
assert.equal(parseEmailArg({ prompt: ' joe' }), null);
assert.equal(parseEmailArg({ prompt: ' joe@' }), null);
assert.equal(parseEmailArg({ prompt: ' joe@ae' }), null);
assert.equal(parseEmailArg({ prompt: ' @ae.com' }), null);
});
it('returns null when the arg is missing', () => {
assert.equal(parseEmailArg({}), null);
assert.equal(parseEmailArg({ prompt: '' }), null);
});
});

62
test/mac.test.js Normal file
View file

@ -0,0 +1,62 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { displayMac, formatMac, normalizeMac } from '../src/utils/mac.js';
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), '—');
});
});

83
test/phones.test.js Normal file
View file

@ -0,0 +1,83 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
WIRED_PHONE_MODELS,
filterWiredPhones,
isSupportedWiredModel,
} from '../src/webex/phones.js';
// Sanity check: the supported models list stays in sync with the two
// models AE actually deploys. Add a case below when a new model lands.
describe('WIRED_PHONE_MODELS', () => {
it('includes both Cisco 7841 and 7821', () => {
const values = WIRED_PHONE_MODELS.map((m) => m.value);
assert.ok(values.includes('Cisco 7841'), '7841 missing');
assert.ok(values.includes('Cisco 7821'), '7821 missing');
});
it('7841 is first (used as the default in the add card)', () => {
assert.equal(WIRED_PHONE_MODELS[0].value, 'Cisco 7841');
});
});
describe('isSupportedWiredModel', () => {
it('accepts the exact strings in WIRED_PHONE_MODELS', () => {
assert.equal(isSupportedWiredModel('Cisco 7841'), true);
assert.equal(isSupportedWiredModel('Cisco 7821'), true);
});
it('is case-sensitive so we always send Webex the canonical name', () => {
assert.equal(isSupportedWiredModel('cisco 7841'), false);
assert.equal(isSupportedWiredModel('CISCO 7841'), false);
});
it('rejects unknown models', () => {
assert.equal(isSupportedWiredModel('Cisco 8845'), false);
assert.equal(isSupportedWiredModel(''), false);
assert.equal(isSupportedWiredModel(undefined), false);
});
});
describe('filterWiredPhones', () => {
it('keeps 78xx MPP phones', () => {
const items = [
{ id: '1', product: 'Cisco 7841' },
{ id: '2', product: 'Cisco 7821' },
];
const kept = filterWiredPhones(items).map((d) => d.id);
assert.deepEqual(kept, ['1', '2']);
});
it('drops DECT devices by product name', () => {
const items = [
{ id: '1', product: 'Cisco 7841' },
{ id: '2', product: 'DMS Cisco DBS210' },
{ id: '3', product: 'DECT Handset' },
];
const kept = filterWiredPhones(items).map((d) => d.id);
assert.deepEqual(kept, ['1']);
});
it('is case-insensitive on the DECT marker so future firmware names still match', () => {
const items = [{ id: '1', product: 'cisco dbs-210' }];
assert.equal(filterWiredPhones(items).length, 0);
});
it('falls back to `model` when `product` is missing', () => {
// Some Webex endpoints report `model` instead of `product`; we
// accept either so filterWiredPhones works against both shapes.
const items = [{ id: '1', model: 'Cisco 7841' }];
assert.equal(filterWiredPhones(items).length, 1);
});
it("drops entries with no model info (can't classify)", () => {
const items = [{ id: '1' }, { id: '2', product: '' }];
assert.equal(filterWiredPhones(items).length, 0);
});
it('accepts an empty array', () => {
assert.deepEqual(filterWiredPhones([]), []);
assert.deepEqual(filterWiredPhones(), []);
});
});