Add /resetvmpin and /resetvppin with confirmation cards for Webex PIN resets.

Chat-only commands reset voicemail mailbox and voice portal passcodes via telephony_config_write, with extension lookup and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-31 16:34:09 -04:00
parent f06dd2d09a
commit 2ec2b7a486
22 changed files with 1308 additions and 3 deletions

View file

@ -62,6 +62,7 @@ WEBEX_BOT_TOKEN=your-bot-token-here
# - identity:tokens_write (offboarduser: revoke a user's authorizations) # - identity:tokens_write (offboarduser: revoke a user's authorizations)
# - spark-admin:calling_cdr_read (/callreport + /calltest CDR via cdr_feed) # - spark-admin:calling_cdr_read (/callreport + /calltest CDR via cdr_feed)
# - analytics:read_all (/callreport Webex Reports API — Pro Pack) # - analytics:read_all (/callreport Webex Reports API — Pro Pack)
# - spark-admin:telephony_config_write (/resetvmpin + /resetvppin PIN resets)
# The authorizing admin must also hold Full / User / Device Admin role for the # The authorizing admin must also hold Full / User / Device Admin role for the
# token-management calls to succeed. # token-management calls to succeed.
# #

View file

@ -38,6 +38,8 @@ const SHORT_HELP = {
vcmonitor: 'On-demand VC packet capture (start/stop/status)', vcmonitor: 'On-demand VC packet capture (start/stop/status)',
offboarduser: 'Offboard user: revoke Webex OAuth tokens + wipe MDM CORP devices', offboarduser: 'Offboard user: revoke Webex OAuth tokens + wipe MDM CORP devices',
webexhost: 'Check / assign Webex Meetings host license on aeo2go.webex.com', webexhost: 'Check / assign Webex Meetings host license on aeo2go.webex.com',
resetvmpin: 'Reset a user voicemail mailbox PIN (email, store, or 5-digit extension)',
resetvppin: 'Reset a store voice portal PIN for auto-attendant greeting access',
// Bulk / utility // Bulk / utility
bulkavstatuscsv: 'Generate full AV devices report (CSV)', bulkavstatuscsv: 'Generate full AV devices report (CSV)',
@ -350,6 +352,34 @@ const LONG_HELP = {
'Every request and outcome is logged under the `offboard:audit` scope with the requesting user (chat email or `via HTTP API`).', 'Every request and outcome is logged under the `offboard:audit` scope with the requesting user (chat email or `via HTTP API`).',
], ],
}, },
resetvmpin: {
title: '/resetvmpin',
usage: ['/resetvmpin <email|store|extension>'],
examples: [
'/resetvmpin mcqueenj@ae.com',
'/resetvmpin 782',
'/resetvmpin 12345',
],
notes: [
'Resets a **voicemail mailbox PIN** for a Webex Calling user. Target can be an email, 24 digit store number (`ae{store}@ae.com`), or a 5-digit extension (unique match on `telephony/config/numbers` only).',
'Posts a confirmation adaptive card showing user identity, match source, location, and voicemail status. A new **6-digit PIN** is generated at confirm time and posted in the same Webex space.',
'Requires `spark-admin:telephony_config_write` (+ existing `people_read` for lookups). Re-authorize the service app after adding the scope.',
'Use trusted spaces — the new PIN is visible to everyone in the room after confirm.',
'Audit log: `resetvmpin:audit` (PIN values are never logged).',
],
},
resetvppin: {
title: '/resetvppin',
usage: ['/resetvppin <store>'],
examples: ['/resetvppin 782'],
notes: [
'Resets the **voice portal passcode** for a store location. Store number only (24 digits) — no email or extension lookup.',
'The voice portal PIN is used to dial in and manage **auto-attendant greetings** for the whole location. The confirmation card shows location name, portal dial-in number, and extension before you confirm.',
'PIN length follows the location `passcodeRules` (not hardcoded 6). Requires `spark-admin:telephony_config_write`.',
'Use trusted spaces — the new PIN is visible to everyone in the room after confirm.',
'Audit log: `resetvppin:audit` (PIN values are never logged).',
],
},
bulkavstatuscsv: { bulkavstatuscsv: {
title: '/bulkavstatuscsv', title: '/bulkavstatuscsv',
usage: ['/bulkavstatuscsv'], usage: ['/bulkavstatuscsv'],
@ -375,7 +405,7 @@ const GROUPS = [
{ title: 'AV & phones', keys: ['avstatus', 'voicestatus', 'wanstatus', 'atlasdiag', 'phonediag', 'dectdiag', 'voicediag', 'callreport', 'calltest'] }, { title: 'AV & phones', keys: ['avstatus', 'voicestatus', 'wanstatus', 'atlasdiag', 'phonediag', 'dectdiag', 'voicediag', 'callreport', 'calltest'] },
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] }, { title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] }, { title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] }, { title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'resetvmpin', 'resetvppin', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
]; ];
function renderTopLevelHelp(isGroup) { function renderTopLevelHelp(isGroup) {

View file

@ -35,6 +35,8 @@ import { handleTestDevicesByModel } from './testDevicesByModel.js';
import { handleCallTest } from './callTest.js'; import { handleCallTest } from './callTest.js';
import { handleCallReport } from './callReport.js'; import { handleCallReport } from './callReport.js';
import { handleAtlasDiag } from './atlasDiag.js'; import { handleAtlasDiag } from './atlasDiag.js';
import { handleResetVmPin } from './resetVmPin.js';
import { handleResetVpPin } from './resetVpPin.js';
/** /**
* Each entry: * Each entry:
@ -82,6 +84,10 @@ export const commands = [
{ name: 'calltest', handler: handleCallTest, mutating: true }, { name: 'calltest', handler: handleCallTest, mutating: true },
{ name: 'offboarduser', handler: handleOffboardUser, mutating: true }, { name: 'offboarduser', handler: handleOffboardUser, mutating: true },
{ name: 'webexhost', handler: handleWebexHost, mutating: true }, { name: 'webexhost', handler: handleWebexHost, mutating: true },
// /resetvmpin and /resetvppin post confirmation adaptive cards and perform
// Webex telephony_config_write PUTs on confirm. Chat-only (http: false).
{ name: 'resetvmpin', handler: handleResetVmPin, mutating: true, http: false },
{ name: 'resetvppin', handler: handleResetVpPin, mutating: true, http: false },
// bulkavstatuscsv delivers a CSV attachment via BotClient.sendWithAttachment, // bulkavstatuscsv delivers a CSV attachment via BotClient.sendWithAttachment,
// which requires a Webex roomId. The HTTP mock trigger has no roomId, so // which requires a Webex roomId. The HTTP mock trigger has no roomId, so
// the command is chat-only; the runtime guard in the handler backstops this. // the command is chat-only; the runtime guard in the handler backstops this.

196
commands/resetVmPin.js Normal file
View file

@ -0,0 +1,196 @@
// commands/resetVmPin.js
//
// /resetvmpin <target> — reset a user's voicemail mailbox PIN with confirmation card.
//
// Targets: email, 24 digit store number, or 5-digit extension.
import { logger } from '../utils/logger.js';
import { pendingVmPinResets } from '../utils/pendingVmPinResets.js';
import { extractRequester, describeRequester } from '../utils/requester.js';
import {
parseVmPinTarget,
resolveVmPinTarget,
} from '../services/voicePin/resolveVmPinTarget.js';
import { resetVoicemailPin } from '../services/voicePin/resetVoicemailPin.js';
function matchSourceLabel(source) {
switch (source) {
case 'email': return 'Email lookup';
case 'store': return 'Store user';
case 'extension': return 'Extension lookup';
default: return source || '—';
}
}
function buildConfirmationCard(cardId, resolved) {
const facts = [
{ title: 'User', value: resolved.displayName },
{ title: 'Email', value: resolved.email },
{ title: 'Person ID', value: resolved.personId },
{ title: 'Match source', value: matchSourceLabel(resolved.matchSource) },
];
if (resolved.storeNum) {
facts.push({ title: 'Store', value: resolved.storeNum });
}
if (resolved.locationName) {
facts.push({ title: 'Location', value: resolved.locationName });
}
if (resolved.mainNumber) {
facts.push({ title: 'Main number', value: resolved.mainNumber });
}
if (resolved.extension) {
facts.push({ title: 'Extension', value: resolved.extension });
}
facts.push({
title: 'Voicemail enabled',
value: resolved.voicemailEnabled == null ? 'Unknown' : (resolved.voicemailEnabled ? 'Yes' : 'No'),
});
return {
type: 'AdaptiveCard',
version: '1.3',
body: [
{
type: 'TextBlock',
text: '🔐 RESET VOICEMAIL PIN',
weight: 'Bolder',
size: 'Large',
color: 'Attention',
},
{
type: 'TextBlock',
text:
'Confirming will generate a new **6-digit voicemail PIN** and apply it immediately. ' +
'The new PIN will be posted in this space after confirmation.',
wrap: true,
spacing: 'Small',
},
{
type: 'FactSet',
spacing: 'Medium',
facts,
},
],
actions: [
{
type: 'Action.Submit',
title: '✅ Confirm Reset',
data: { action: 'confirm_reset_vmpin', cardId },
},
{
type: 'Action.Submit',
title: '❌ Cancel',
data: { action: 'cancel_reset_vmpin', cardId },
},
],
};
}
export async function applyResetVmPinConfirmation(bot, data, _roomId, requester) {
logger(
'resetvmpin:audit',
`CONFIRMED voicemail PIN reset for ${data.email} (person=${data.personId}) by ${describeRequester(requester)}`,
);
try {
const newPin = await resetVoicemailPin(data.personId);
await bot.say(
'markdown',
`✅ Voicemail PIN reset for **${data.displayName}** (\`${data.email}\`).\n\n` +
`**New voicemail PIN:** \`${newPin}\`\n\n` +
'_Share this PIN only in trusted spaces._',
);
logger(
'resetvmpin:audit',
`COMPLETED voicemail PIN reset for ${data.email} (person=${data.personId}) by ${describeRequester(requester)}`,
);
} catch (err) {
const apiMsg = err?.response?.data?.message || err.message;
logger(
'resetvmpin:audit',
`FAILED voicemail PIN reset for ${data.email}: ${apiMsg}`,
'error',
);
await bot.say(
'markdown',
`❌ Voicemail PIN reset failed for **${data.displayName}**: ${apiMsg}`,
);
}
}
export async function cancelResetVmPinCard(bot, data, _roomId, requester) {
await bot.say(
'markdown',
`❌ Voicemail PIN reset cancelled for **${data.displayName}** (\`${data.email}\`). No changes were made.`,
);
logger(
'resetvmpin:audit',
`CANCELLED voicemail PIN reset for ${data.email} by ${describeRequester(requester)}`,
);
}
export async function handleResetVmPin(bot, trigger) {
const args = trigger.args || [];
const query = trigger.query || {};
const target = (args[0] || query.target || query.email || query.store || query.extension || '').trim();
if (!target) {
await bot.say(
'markdown',
'**Usage:** `/resetvmpin <email|store|extension>`\n\n' +
'• Email: `user@ae.com`\n' +
'• Store: `782` (24 digits)\n' +
'• Extension: `12345` (5 digits)',
);
return;
}
const parsed = parseVmPinTarget(target);
if (!parsed) {
await bot.say(
'markdown',
`❌ Invalid target \`${target}\`. Use email, 24 digit store number, or 5-digit extension.`,
);
return;
}
const requester = extractRequester(trigger);
logger(
'resetvmpin:audit',
`REQUESTED voicemail PIN reset card for ${target} by ${describeRequester(requester)}`,
);
try {
const resolved = await resolveVmPinTarget(parsed);
if (!trigger.person) {
await bot.say(
'markdown',
`Resolved **${resolved.displayName}** (\`${resolved.email}\`). ` +
'Confirmation cards are only available in Webex chat.',
);
return;
}
const cardId = `vmpin-${Date.now()}`;
pendingVmPinResets.set(cardId, {
...resolved,
roomId: trigger.roomId || trigger.message?.roomId,
requester,
});
const adaptiveCard = buildConfirmationCard(cardId, resolved);
await bot.say({
markdown:
`Review voicemail PIN reset for **${resolved.displayName}** (\`${resolved.email}\`):`,
attachments: [{
contentType: 'application/vnd.microsoft.card.adaptive',
content: adaptiveCard,
}],
});
} catch (err) {
logger('resetvmpin', `Resolve failed for ${target}: ${err.message}`, 'warn');
await bot.say('markdown', `${err.message}`);
}
}

186
commands/resetVpPin.js Normal file
View file

@ -0,0 +1,186 @@
// commands/resetVpPin.js
//
// /resetvppin <store> — reset a location's voice portal PIN with confirmation card.
//
// Store number only (24 digits). Used to manage auto-attendant greetings.
import { logger } from '../utils/logger.js';
import { pendingVpPinResets } from '../utils/pendingVpPinResets.js';
import { extractRequester, describeRequester } from '../utils/requester.js';
import {
parseVpPinStore,
resolveVpPinStore,
} from '../services/voicePin/resolveVpPinStore.js';
import { resetVoicePortalPin } from '../services/voicePin/resetVoicePortalPin.js';
function buildConfirmationCard(cardId, resolved) {
const portal = resolved.voicePortal || {};
const dialIn = portal.phoneNumber
? `${portal.phoneNumber}${portal.extension ? ` ext ${portal.extension}` : ''}`
: '—';
return {
type: 'AdaptiveCard',
version: '1.3',
body: [
{
type: 'TextBlock',
text: '🔐 RESET VOICE PORTAL PIN',
weight: 'Bolder',
size: 'Large',
color: 'Attention',
},
{
type: 'TextBlock',
text:
'Confirming will generate a new voice portal passcode for this location. ' +
'This PIN is used to dial in and manage **auto-attendant greetings**.',
wrap: true,
spacing: 'Small',
},
{
type: 'FactSet',
spacing: 'Medium',
facts: [
{ title: 'Store', value: resolved.storeNum },
{ title: 'Location', value: resolved.locationName || '—' },
{ title: 'Location ID', value: resolved.locationId },
{ title: 'Voice portal', value: portal.name || '—' },
{ title: 'Dial-in', value: dialIn },
{ title: 'Main number', value: resolved.mainNumber || '—' },
],
},
],
actions: [
{
type: 'Action.Submit',
title: '✅ Confirm Reset',
data: { action: 'confirm_reset_vppin', cardId },
},
{
type: 'Action.Submit',
title: '❌ Cancel',
data: { action: 'cancel_reset_vppin', cardId },
},
],
};
}
export async function applyResetVpPinConfirmation(bot, data, _roomId, requester) {
logger(
'resetvppin:audit',
`CONFIRMED voice portal PIN reset for store ${data.storeNum} (location=${data.locationId}) ` +
`by ${describeRequester(requester)}`,
);
try {
const newPin = await resetVoicePortalPin(data.locationId, {
passcodeRules: data.passcodeRules,
voicePortal: data.voicePortal,
});
const portal = data.voicePortal || {};
const dialIn = portal.phoneNumber
? `${portal.phoneNumber}${portal.extension ? ` ext ${portal.extension}` : ''}`
: null;
await bot.say(
'markdown',
`✅ Voice portal PIN reset for **Store ${data.storeNum}** ` +
`(\`${data.locationName || data.locationId}\`).\n\n` +
`**New voice portal PIN:** \`${newPin}\`\n` +
(dialIn ? `\nDial in at **${dialIn}** to manage auto-attendant greetings.\n` : '\n') +
'_Share this PIN only in trusted spaces._',
);
logger(
'resetvppin:audit',
`COMPLETED voice portal PIN reset for store ${data.storeNum} (location=${data.locationId}) ` +
`by ${describeRequester(requester)}`,
);
} catch (err) {
const apiMsg = err?.response?.data?.message || err.message;
logger(
'resetvppin:audit',
`FAILED voice portal PIN reset for store ${data.storeNum}: ${apiMsg}`,
'error',
);
await bot.say(
'markdown',
`❌ Voice portal PIN reset failed for **Store ${data.storeNum}**: ${apiMsg}`,
);
}
}
export async function cancelResetVpPinCard(bot, data, _roomId, requester) {
await bot.say(
'markdown',
`❌ Voice portal PIN reset cancelled for **Store ${data.storeNum}**. No changes were made.`,
);
logger(
'resetvppin:audit',
`CANCELLED voice portal PIN reset for store ${data.storeNum} by ${describeRequester(requester)}`,
);
}
export async function handleResetVpPin(bot, trigger) {
const args = trigger.args || [];
const query = trigger.query || {};
const raw = (args[0] || query.store || query.storeNum || query.s || '').trim();
if (!raw) {
await bot.say(
'markdown',
'**Usage:** `/resetvppin <store>`\n\n' +
'Provide a 24 digit store number (e.g. `782`).',
);
return;
}
const parsed = parseVpPinStore(raw);
if (!parsed) {
await bot.say(
'markdown',
`❌ Invalid store \`${raw}\`. Use a 24 digit store number.`,
);
return;
}
const requester = extractRequester(trigger);
logger(
'resetvppin:audit',
`REQUESTED voice portal PIN reset card for store ${parsed.storeNum} by ${describeRequester(requester)}`,
);
try {
const resolved = await resolveVpPinStore(parsed);
if (!trigger.person) {
await bot.say(
'markdown',
`Resolved **Store ${resolved.storeNum}** → \`${resolved.locationName || resolved.locationId}\`. ` +
'Confirmation cards are only available in Webex chat.',
);
return;
}
const cardId = `vppin-${Date.now()}`;
pendingVpPinResets.set(cardId, {
...resolved,
roomId: trigger.roomId || trigger.message?.roomId,
requester,
});
const adaptiveCard = buildConfirmationCard(cardId, resolved);
await bot.say({
markdown:
`Review voice portal PIN reset for **Store ${resolved.storeNum}** ` +
`(\`${resolved.locationName || resolved.locationId}\`):`,
attachments: [{
contentType: 'application/vnd.microsoft.card.adaptive',
content: adaptiveCard,
}],
});
} catch (err) {
logger('resetvppin', `Resolve failed for store ${raw}: ${err.message}`, 'warn');
await bot.say('markdown', `${err.message}`);
}
}

View file

@ -45,6 +45,16 @@ import {
cancelAllVoiceDiagRemediations, cancelAllVoiceDiagRemediations,
} from './commands/voiceDiag.js'; } from './commands/voiceDiag.js';
import { pendingVoiceFixes } from './utils/pendingVoiceFixes.js'; import { pendingVoiceFixes } from './utils/pendingVoiceFixes.js';
import {
applyResetVmPinConfirmation,
cancelResetVmPinCard,
} from './commands/resetVmPin.js';
import { pendingVmPinResets } from './utils/pendingVmPinResets.js';
import {
applyResetVpPinConfirmation,
cancelResetVpPinCard,
} from './commands/resetVpPin.js';
import { pendingVpPinResets } from './utils/pendingVpPinResets.js';
import { extractRequester } from './utils/requester.js'; import { extractRequester } from './utils/requester.js';
import { getDectRelayHub } from './services/dectRelayHub.js'; import { getDectRelayHub } from './services/dectRelayHub.js';
import { import {
@ -392,6 +402,8 @@ const VOICEDIAG_ACTIONS = new Set([
'confirm_voicediag_all', 'confirm_voicediag_all',
'cancel_voicediag_all', 'cancel_voicediag_all',
]); ]);
const RESET_VMPIN_ACTIONS = new Set(['confirm_reset_vmpin', 'cancel_reset_vmpin']);
const RESET_VPPIN_ACTIONS = new Set(['confirm_reset_vppin', 'cancel_reset_vppin']);
// Best-effort delete of the adaptive-card message that fired this action. // Best-effort delete of the adaptive-card message that fired this action.
// Removing the card prevents users from clicking Confirm/Cancel a second time // Removing the card prevents users from clicking Confirm/Cancel a second time
@ -657,6 +669,90 @@ framework.on('attachmentAction', async (bot, trigger) => {
return; return;
} }
// ── /resetvmpin confirm / cancel ──
if (RESET_VMPIN_ACTIONS.has(actionType)) {
const { cardId } = action.inputs;
const roomId = trigger.roomId || action.roomId;
if (!cardId) {
logger('resetvmpin:action', `Missing cardId on ${actionType} — ignoring`);
return;
}
if (!pendingVmPinResets.has(cardId)) {
logger('resetvmpin:action', `Card ${cardId} is expired or unknown`);
return;
}
const vmPinData = pendingVmPinResets.get(cardId);
pendingVmPinResets.delete(cardId);
logger(
'resetvmpin:action',
`Received ${actionType} for card ${cardId} (user: ${vmPinData.email})`,
);
await censorActionCard(bot, trigger, 'resetvmpin:action');
const requester = extractRequester(trigger);
try {
if (actionType === 'confirm_reset_vmpin') {
await applyResetVmPinConfirmation(bot, vmPinData, roomId, requester);
} else {
await cancelResetVmPinCard(bot, vmPinData, roomId, requester);
}
} catch (err) {
logger(
'resetvmpin:action',
`Error processing ${actionType} for ${vmPinData.email}: ${err.message}`,
'error',
);
await bot.say('markdown', `⚠️ Error during voicemail PIN reset: ${err.message}`);
}
return;
}
// ── /resetvppin confirm / cancel ──
if (RESET_VPPIN_ACTIONS.has(actionType)) {
const { cardId } = action.inputs;
const roomId = trigger.roomId || action.roomId;
if (!cardId) {
logger('resetvppin:action', `Missing cardId on ${actionType} — ignoring`);
return;
}
if (!pendingVpPinResets.has(cardId)) {
logger('resetvppin:action', `Card ${cardId} is expired or unknown`);
return;
}
const vpPinData = pendingVpPinResets.get(cardId);
pendingVpPinResets.delete(cardId);
logger(
'resetvppin:action',
`Received ${actionType} for card ${cardId} (store: ${vpPinData.storeNum})`,
);
await censorActionCard(bot, trigger, 'resetvppin:action');
const requester = extractRequester(trigger);
try {
if (actionType === 'confirm_reset_vppin') {
await applyResetVpPinConfirmation(bot, vpPinData, roomId, requester);
} else {
await cancelResetVpPinCard(bot, vpPinData, roomId, requester);
}
} catch (err) {
logger(
'resetvppin:action',
`Error processing ${actionType} for store ${vpPinData.storeNum}: ${err.message}`,
'error',
);
await bot.say('markdown', `⚠️ Error during voice portal PIN reset: ${err.message}`);
}
return;
}
logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug'); logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug');
}); });

View file

@ -76,6 +76,7 @@ async function buildNumbersCache() {
const numbers = await fetchAllNumbers(); const numbers = await fetchAllNumbers();
const byPhoneDigits = new Map(); const byPhoneDigits = new Map();
const byPersonId = new Map(); const byPersonId = new Map();
const byExtension = new Map();
const records = []; const records = [];
for (const n of numbers) { for (const n of numbers) {
@ -93,13 +94,21 @@ async function buildNumbersCache() {
if (!byPersonId.has(ownerId)) byPersonId.set(ownerId, []); if (!byPersonId.has(ownerId)) byPersonId.set(ownerId, []);
byPersonId.get(ownerId).push(entry); byPersonId.get(ownerId).push(entry);
} }
if (entry.extension) {
const ext = String(entry.extension).trim();
if (ext) {
if (!byExtension.has(ext)) byExtension.set(ext, []);
byExtension.get(ext).push(entry);
}
}
} }
_numbersCache = { byPhoneDigits, byPersonId, records }; _numbersCache = { byPhoneDigits, byPersonId, byExtension, records };
_numbersCacheAt = now; _numbersCacheAt = now;
logger( logger(
LOG_SCOPE, LOG_SCOPE,
`Phone index built: ${byPhoneDigits.size} phone keys, ${byPersonId.size} owners from ${numbers.length} numbers`, `Phone index built: ${byPhoneDigits.size} phone keys, ${byPersonId.size} owners, ${byExtension.size} extensions from ${numbers.length} numbers`,
'debug', 'debug',
); );
return _numbersCache; return _numbersCache;
@ -218,7 +227,29 @@ export async function buildPhoneIndex() {
return cache.byPhoneDigits; return cache.byPhoneDigits;
} }
/**
* Find telephony number records assigned to a Webex extension.
* @param {string} extension
* @returns {Promise<object[]>}
*/
export async function resolveRecordsByExtension(extension) {
if (!extension) return [];
try {
const cache = await buildNumbersCache();
const ext = String(extension).trim();
return cache.byExtension.get(ext) || [];
} catch (err) {
logger(LOG_SCOPE, `resolveRecordsByExtension failed: ${err.message}`, 'warn');
return [];
}
}
export function _clearLocationCacheForTests() { export function _clearLocationCacheForTests() {
_numbersCache = null; _numbersCache = null;
_numbersCacheAt = 0; _numbersCacheAt = 0;
} }
export function _setNumbersCacheForTests(cache) {
_numbersCache = cache;
_numbersCacheAt = Date.now();
}

View file

@ -0,0 +1,76 @@
// services/voicePin/generatePin.js
// Crypto-random PIN generation with configurable length and passcode rules.
import { randomInt } from 'node:crypto';
const DEFAULT_MIN = 6;
const DEFAULT_MAX = 6;
function digitPin(length) {
let pin = '';
for (let i = 0; i < length; i += 1) {
pin += String(randomInt(0, 10));
}
return pin;
}
function hasSequentialDigits(pin, maxRun = 3) {
for (let i = 0; i <= pin.length - maxRun; i += 1) {
const slice = pin.slice(i, i + maxRun);
const asc = slice.split('').every((d, idx) => idx === 0 || Number(d) === Number(slice[idx - 1]) + 1);
const desc = slice.split('').every((d, idx) => idx === 0 || Number(d) === Number(slice[idx - 1]) - 1);
if (asc || desc) return true;
}
return false;
}
function isAllSameDigit(pin) {
return /^(\d)\1+$/.test(pin);
}
function violatesRules(pin, rules = {}) {
const length = pin.length;
const minLength = rules.minLength ?? rules.length?.min ?? DEFAULT_MIN;
const maxLength = rules.maxLength ?? rules.length?.max ?? DEFAULT_MAX;
if (length < minLength || length > maxLength) return true;
if (rules.disallowSequential === true && hasSequentialDigits(pin)) return true;
if (rules.disallowRepeating === true && isAllSameDigit(pin)) return true;
if (rules.disallowSameAsExtension === true && rules.extension && pin === String(rules.extension)) {
return true;
}
return false;
}
/**
* Generate a numeric PIN that satisfies the provided rules.
*
* @param {object} [opts]
* @param {number} [opts.minLength]
* @param {number} [opts.maxLength]
* @param {object} [opts.rules] full passcodeRules object from Webex
* @param {number} [opts.maxAttempts]
* @returns {string}
*/
export function generatePin(opts = {}) {
const rules = opts.rules || {};
const minLength = opts.minLength ?? rules.length?.min ?? DEFAULT_MIN;
const maxLength = opts.maxLength ?? rules.length?.max ?? minLength;
const targetLength = Math.max(minLength, Math.min(maxLength, opts.length ?? minLength));
const maxAttempts = opts.maxAttempts ?? 50;
const mergedRules = {
...rules,
minLength,
maxLength,
extension: opts.extension ?? rules.extension,
};
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const pin = digitPin(targetLength);
if (!violatesRules(pin, mergedRules)) return pin;
}
throw new Error(`Could not generate a compliant PIN after ${maxAttempts} attempts`);
}

View file

@ -0,0 +1,25 @@
// services/voicePin/parseVmPinTarget.js
const STORE_RE = /^\d{2,4}$/;
const EXTENSION_RE = /^\d{5}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/i;
/**
* @param {string} raw
* @returns {{ kind: 'email', email: string } | { kind: 'store', storeNum: string } | { kind: 'extension', extension: string } | null}
*/
export function parseVmPinTarget(raw) {
const token = String(raw || '').trim();
if (!token) return null;
if (EMAIL_RE.test(token)) {
return { kind: 'email', email: token.toLowerCase() };
}
if (STORE_RE.test(token)) {
return { kind: 'store', storeNum: token };
}
if (EXTENSION_RE.test(token)) {
return { kind: 'extension', extension: token };
}
return null;
}

View file

@ -0,0 +1,13 @@
// services/voicePin/parseVpPinStore.js
const STORE_RE = /^\d{2,4}$/;
/**
* @param {string} raw
* @returns {{ storeNum: string } | null}
*/
export function parseVpPinStore(raw) {
const storeNum = String(raw || '').trim();
if (!STORE_RE.test(storeNum)) return null;
return { storeNum };
}

View file

@ -0,0 +1,79 @@
// services/voicePin/resetVoicePortalPin.js
// GET passcodeRules + PUT /v1/telephony/config/locations/{id}/voicePortal passcode
import { logger } from '../../utils/logger.js';
import { generatePin } from './generatePin.js';
const LOG_SCOPE = 'resetvppin:service';
const MAX_PUT_ATTEMPTS = 5;
/**
* Reset a location's voice portal passcode.
*
* @param {string} locationId
* @param {object} [opts]
* @param {object} [opts.passcodeRules] cached rules from resolve step
* @param {object} [opts.voicePortal] cached portal snapshot from resolve step
* @param {object} [opts.webexClient] injectable client for tests
* @returns {Promise<string>} new PIN (never log this value)
*/
export async function resetVoicePortalPin(locationId, opts = {}) {
if (!locationId) {
throw new Error('locationId is required');
}
const client = opts.webexClient || (await import('../../integrations/webex/WebexClient.js')).default;
const passcodeRules = opts.passcodeRules
|| await client.request('GET', `telephony/config/locations/${locationId}/voicePortal/passcodeRules`);
let lastError = null;
for (let attempt = 0; attempt < MAX_PUT_ATTEMPTS; attempt += 1) {
const passcode = generatePin({
rules: passcodeRules,
extension: opts.voicePortal?.extension,
});
const body = {
passcode: {
newPasscode: passcode,
confirmPasscode: passcode,
},
};
try {
await client.request('PUT', `telephony/config/locations/${locationId}/voicePortal`, body);
logger(LOG_SCOPE, `Voice portal PIN reset succeeded for location ${locationId}`, 'info');
return passcode;
} catch (err) {
lastError = err;
const status = err?.response?.status;
if (status === 400 && attempt < MAX_PUT_ATTEMPTS - 1) {
logger(LOG_SCOPE, `Voice portal PIN rejected for ${locationId} (attempt ${attempt + 1}), retrying`, 'warn');
continue;
}
if (status === 400 || status === 422) {
const existing = opts.voicePortal?.raw
|| await client.request('GET', `telephony/config/locations/${locationId}/voicePortal`).catch(() => null);
if (existing) {
const merged = {
...existing,
passcode: body.passcode,
};
try {
await client.request('PUT', `telephony/config/locations/${locationId}/voicePortal`, merged);
logger(LOG_SCOPE, `Voice portal PIN reset succeeded (merged PUT) for location ${locationId}`, 'info');
return passcode;
} catch (mergeErr) {
lastError = mergeErr;
}
}
}
throw lastError;
}
}
throw lastError || new Error('Voice portal PIN reset failed');
}

View file

@ -0,0 +1,54 @@
// services/voicePin/resetVoicemailPin.js
// PUT /v1/telephony/config/people/{personId}/voicemail/passcode
//
// Admin API to set a custom voicemail PIN (spark-admin:telephony_config_write).
// Do NOT use people/{id}/features/voicemail/passcode — that path does not exist.
// For org-default reset only: POST people/{id}/features/voicemail/actions/resetPin/invoke
import { logger } from '../../utils/logger.js';
import { generatePin } from './generatePin.js';
const LOG_SCOPE = 'resetvmpin:service';
const VM_PIN_LENGTH = 6;
const MAX_PUT_ATTEMPTS = 5;
function vmPasscodeEndpoint(personId) {
return `telephony/config/people/${personId}/voicemail/passcode`;
}
/**
* Reset a user's voicemail mailbox PIN.
*
* @param {string} personId
* @param {object} [opts]
* @param {object} [opts.webexClient] injectable client for tests
* @returns {Promise<string>} new PIN (never log this value)
*/
export async function resetVoicemailPin(personId, opts = {}) {
if (!personId) {
throw new Error('personId is required');
}
const client = opts.webexClient || (await import('../../integrations/webex/WebexClient.js')).default;
const endpoint = vmPasscodeEndpoint(personId);
let lastError = null;
for (let attempt = 0; attempt < MAX_PUT_ATTEMPTS; attempt += 1) {
const passcode = generatePin({ minLength: VM_PIN_LENGTH, maxLength: VM_PIN_LENGTH });
try {
await client.request('PUT', endpoint, { passcode });
logger(LOG_SCOPE, `Voicemail PIN reset succeeded for person ${personId}`, 'info');
return passcode;
} catch (err) {
lastError = err;
const status = err?.response?.status;
if (status === 400 && attempt < MAX_PUT_ATTEMPTS - 1) {
logger(LOG_SCOPE, `Voicemail PIN rejected for ${personId} (attempt ${attempt + 1}), retrying`, 'warn');
continue;
}
throw err;
}
}
throw lastError || new Error('Voicemail PIN reset failed');
}

View file

@ -0,0 +1,126 @@
// services/voicePin/resolveVmPinTarget.js
// Parse and resolve /resetvmpin targets: email, store (24 digits), or extension (5 digits).
import { logger } from '../../utils/logger.js';
import { getPersonDetails } from '../phoneService.js';
import {
resolvePersonLocation,
resolveRecordsByExtension,
} from '../callTest/locationResolver.js';
export { parseVmPinTarget } from './parseVmPinTarget.js';
const LOG_SCOPE = 'resetvmpin:resolve';
/**
* @typedef {'email'|'store'|'extension'} VmPinMatchSource
*/
async function getWebex() {
const mod = await import('../../integrations/webex/WebexClient.js');
return mod.default;
}
async function fetchVoicemailSnapshot(personId) {
if (!personId) return { enabled: null };
try {
const webex = await getWebex();
const vm = await webex.request('GET', `people/${personId}/features/voicemail`);
return { enabled: vm?.enabled === true, raw: vm };
} catch (err) {
logger(LOG_SCOPE, `Voicemail feature fetch failed for ${personId}: ${err.message}`, 'warn');
return { enabled: null };
}
}
/**
* Resolve a parsed VM PIN target to a Webex person + context for the confirmation card.
*
* @param {ReturnType<typeof parseVmPinTarget>} parsed
* @returns {Promise<object>}
*/
export async function resolveVmPinTarget(parsed) {
if (!parsed) {
throw new Error('Invalid target. Use email, 24 digit store number, or 5-digit extension.');
}
const webex = await getWebex();
let personId = null;
let email = null;
let matchSource;
let storeNum = null;
let extension = null;
let numberRecord = null;
if (parsed.kind === 'email') {
matchSource = 'email';
email = parsed.email;
const person = await webex.findPersonByEmail(email);
if (!person) {
throw new Error(`No Webex person found for ${email}`);
}
personId = person.id;
} else if (parsed.kind === 'store') {
matchSource = 'store';
storeNum = parsed.storeNum;
const padded = storeNum.padStart(5, '0');
email = `ae${padded}@ae.com`;
const person = await webex.findPersonByEmail(email);
if (!person) {
throw new Error(`No Webex person found for store ${storeNum} (${email})`);
}
personId = person.id;
} else {
matchSource = 'extension';
extension = parsed.extension;
const records = await resolveRecordsByExtension(extension);
if (records.length === 0) {
throw new Error(`No telephony number found for extension ${extension}`);
}
const ownerIds = [...new Set(records.map((r) => r.owner?.id).filter(Boolean))];
if (ownerIds.length !== 1) {
throw new Error(
`Extension ${extension} matches ${records.length} number(s) across ${ownerIds.length} owner(s) — cannot resolve a unique user`,
);
}
personId = ownerIds[0];
numberRecord = records[0];
}
const [person, locationInfo, voicemail] = await Promise.all([
getPersonDetails(personId),
resolvePersonLocation(personId),
fetchVoicemailSnapshot(personId),
]);
if (!person) {
throw new Error(`Could not load Webex person ${personId}`);
}
if (!email) {
email = (person.emails || []).find((e) => typeof e === 'string' && e.includes('@'))
|| person.emails?.[0]?.value
|| person.emails?.[0]
|| null;
}
const ownedNumbers = locationInfo?.ownedNumbers || [];
const primaryNumber = numberRecord
|| ownedNumbers.find((n) => n.extension === extension)
|| ownedNumbers[0]
|| null;
return {
personId,
person,
email: email || person.displayName || personId,
displayName: person.displayName || email || personId,
matchSource,
storeNum,
extension: extension || primaryNumber?.extension || null,
locationId: locationInfo?.locationId || primaryNumber?.locationId || null,
locationName: locationInfo?.locationName || primaryNumber?.locationName || null,
mainNumber: primaryNumber?.phoneNumber || null,
voicemailEnabled: voicemail.enabled,
};
}

View file

@ -0,0 +1,56 @@
// services/voicePin/resolveVpPinStore.js
// Resolve a store number to location + voice portal snapshot for /resetvppin.
import { logger } from '../../utils/logger.js';
import { resolveStoreMainNumber } from '../callTest/storeResolver.js';
export { parseVpPinStore } from './parseVpPinStore.js';
const LOG_SCOPE = 'resetvppin:resolve';
async function getWebex() {
const mod = await import('../../integrations/webex/WebexClient.js');
return mod.default;
}
/**
* @param {{ storeNum: string }} parsed
* @returns {Promise<object>}
*/
export async function resolveVpPinStore(parsed) {
if (!parsed?.storeNum) {
throw new Error('Invalid store number. Use a 24 digit store number.');
}
const store = await resolveStoreMainNumber(parsed.storeNum);
const { locationId, locationName, storeNum, dialNumber } = store;
let voicePortal = null;
let passcodeRules = null;
try {
const webex = await getWebex();
[voicePortal, passcodeRules] = await Promise.all([
webex.request('GET', `telephony/config/locations/${locationId}/voicePortal`),
webex.request('GET', `telephony/config/locations/${locationId}/voicePortal/passcodeRules`),
]);
} catch (err) {
logger(LOG_SCOPE, `Voice portal fetch failed for location ${locationId}: ${err.message}`, 'warn');
throw new Error(`Could not load voice portal for store ${storeNum}: ${err.message}`);
}
return {
storeNum,
locationId,
locationName,
mainNumber: dialNumber,
voicePortal: {
name: voicePortal?.name || null,
extension: voicePortal?.extension || null,
phoneNumber: voicePortal?.phoneNumber || null,
language: voicePortal?.language || null,
raw: voicePortal,
},
passcodeRules: passcodeRules || {},
};
}

View file

@ -0,0 +1,48 @@
// tests/resetVmPin.extensionIndex.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
resolveRecordsByExtension,
_clearLocationCacheForTests,
_setNumbersCacheForTests,
} from '../services/callTest/locationResolver.js';
test('resolveRecordsByExtension returns records from byExtension index', async () => {
_clearLocationCacheForTests();
const record = {
locationId: 'LOC1',
locationName: 'Store 0782',
phoneNumber: '+17247795574',
extension: '50782',
owner: { id: 'PERSON1', name: 'Store 0782' },
};
_setNumbersCacheForTests({
byPhoneDigits: new Map(),
byPersonId: new Map([['PERSON1', [record]]]),
byExtension: new Map([['50782', [record]]]),
records: [record],
});
const matches = await resolveRecordsByExtension('50782');
assert.equal(matches.length, 1);
assert.equal(matches[0].owner.id, 'PERSON1');
_clearLocationCacheForTests();
});
test('resolveRecordsByExtension returns empty for unknown extension', async () => {
_clearLocationCacheForTests();
_setNumbersCacheForTests({
byPhoneDigits: new Map(),
byPersonId: new Map(),
byExtension: new Map(),
records: [],
});
const matches = await resolveRecordsByExtension('99999');
assert.deepEqual(matches, []);
_clearLocationCacheForTests();
});

View file

@ -0,0 +1,39 @@
// tests/resetVmPin.generatePin.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { generatePin } from '../services/voicePin/generatePin.js';
test('generatePin returns fixed 6-digit PIN by default', () => {
const pin = generatePin();
assert.match(pin, /^\d{6}$/);
});
test('generatePin respects min and max length from rules', () => {
const pin = generatePin({ rules: { length: { min: 4, max: 4 } } });
assert.match(pin, /^\d{4}$/);
});
test('generatePin avoids all-same-digit when disallowRepeating is set', () => {
for (let i = 0; i < 20; i += 1) {
const pin = generatePin({
minLength: 4,
maxLength: 4,
rules: { disallowRepeating: true },
});
assert.notEqual(/^(\d)\1+$/.test(pin), true);
}
});
test('generatePin avoids matching extension when disallowSameAsExtension is set', () => {
for (let i = 0; i < 20; i += 1) {
const pin = generatePin({
minLength: 5,
maxLength: 5,
extension: '12345',
rules: { disallowSameAsExtension: true },
});
assert.notEqual(pin, '12345');
}
});

View file

@ -0,0 +1,28 @@
// tests/resetVmPin.parse.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseVmPinTarget } from '../services/voicePin/parseVmPinTarget.js';
test('parseVmPinTarget recognizes email addresses', () => {
assert.deepEqual(parseVmPinTarget('mcqueenj@ae.com'), {
kind: 'email',
email: 'mcqueenj@ae.com',
});
});
test('parseVmPinTarget recognizes store numbers', () => {
assert.deepEqual(parseVmPinTarget('782'), { kind: 'store', storeNum: '782' });
assert.deepEqual(parseVmPinTarget('24'), { kind: 'store', storeNum: '24' });
});
test('parseVmPinTarget recognizes 5-digit extensions', () => {
assert.deepEqual(parseVmPinTarget('12345'), { kind: 'extension', extension: '12345' });
});
test('parseVmPinTarget rejects invalid tokens', () => {
assert.equal(parseVmPinTarget(''), null);
assert.equal(parseVmPinTarget('123456'), null);
assert.equal(parseVmPinTarget('not-a-target'), null);
});

View file

@ -0,0 +1,44 @@
// tests/resetVmPin.reset.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { resetVoicemailPin } from '../services/voicePin/resetVoicemailPin.js';
test('resetVoicemailPin PUTs a 6-digit passcode', async () => {
const calls = [];
const webexClient = {
async request(method, endpoint, body) {
calls.push({ method, endpoint, body });
return {};
},
};
const pin = await resetVoicemailPin('PERSON123', { webexClient });
assert.match(pin, /^\d{6}$/);
assert.equal(calls.length, 1);
assert.equal(calls[0].method, 'PUT');
assert.equal(calls[0].endpoint, 'telephony/config/people/PERSON123/voicemail/passcode');
assert.equal(calls[0].body.passcode, pin);
});
test('resetVoicemailPin retries on HTTP 400', async () => {
let attempt = 0;
const webexClient = {
async request() {
attempt += 1;
if (attempt === 1) {
const err = new Error('rejected');
err.response = { status: 400 };
throw err;
}
return {};
},
};
const pin = await resetVoicemailPin('PERSON123', { webexClient });
assert.match(pin, /^\d{6}$/);
assert.equal(attempt, 2);
});

View file

@ -0,0 +1,18 @@
// tests/resetVpPin.parse.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseVpPinStore } from '../services/voicePin/parseVpPinStore.js';
test('parseVpPinStore recognizes store numbers', () => {
assert.deepEqual(parseVpPinStore('782'), { storeNum: '782' });
assert.deepEqual(parseVpPinStore('24'), { storeNum: '24' });
});
test('parseVpPinStore rejects non-store tokens', () => {
assert.equal(parseVpPinStore(''), null);
assert.equal(parseVpPinStore('12345'), null);
assert.equal(parseVpPinStore('mcqueenj@ae.com'), null);
assert.equal(parseVpPinStore('50782'), null);
});

View file

@ -0,0 +1,61 @@
// tests/resetVpPin.reset.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { resetVoicePortalPin } from '../services/voicePin/resetVoicePortalPin.js';
test('resetVoicePortalPin PUTs passcode using location rules length', async () => {
const calls = [];
const webexClient = {
async request(method, endpoint, body) {
calls.push({ method, endpoint, body });
return {};
},
};
const pin = await resetVoicePortalPin('LOC123', {
passcodeRules: { length: { min: 4, max: 4 } },
voicePortal: { extension: '9000', raw: { name: 'Portal', extension: '9000' } },
webexClient,
});
assert.match(pin, /^\d{4}$/);
assert.equal(calls.length, 1);
assert.equal(calls[0].method, 'PUT');
assert.equal(calls[0].endpoint, 'telephony/config/locations/LOC123/voicePortal');
assert.deepEqual(calls[0].body.passcode, {
newPasscode: pin,
confirmPasscode: pin,
});
});
test('resetVoicePortalPin merges full portal object on partial PUT failure', async () => {
let putAttempts = 0;
const webexClient = {
async request(method, endpoint, body) {
if (method === 'PUT') {
putAttempts += 1;
if (putAttempts === 1) {
const err = new Error('partial rejected');
err.response = { status: 400 };
throw err;
}
return body;
}
return { name: 'Store Portal', extension: '9000', phoneNumber: '+15551234567' };
},
};
const pin = await resetVoicePortalPin('LOC123', {
passcodeRules: { length: { min: 6, max: 6 } },
voicePortal: {
extension: '9000',
raw: { name: 'Store Portal', extension: '9000', phoneNumber: '+15551234567' },
},
webexClient,
});
assert.match(pin, /^\d{6}$/);
assert.equal(putAttempts, 2);
});

View file

@ -0,0 +1,46 @@
// utils/pendingVmPinResets.js
//
// In-memory store for pending voicemail PIN reset confirmation cards.
import { logger } from './logger.js';
const TTL_MS = 10 * 60 * 1000;
const SWEEP_INTERVAL_MS = 60 * 1000;
const _store = new Map();
export const pendingVmPinResets = {
set(cardId, data) {
_store.set(cardId, { ...data, timestamp: Date.now() });
return this;
},
get(cardId) {
return _store.get(cardId);
},
has(cardId) {
return _store.has(cardId);
},
delete(cardId) {
return _store.delete(cardId);
},
get size() {
return _store.size;
},
};
const sweepHandle = setInterval(() => {
const now = Date.now();
for (const [cardId, data] of _store.entries()) {
const stamped = typeof data?.timestamp === 'number' ? data.timestamp : 0;
if (now - stamped > TTL_MS) {
logger('resetvmpin:cleanup', `Expired card ${cardId} for ${data?.email || 'unknown'}`);
_store.delete(cardId);
}
}
}, SWEEP_INTERVAL_MS);
sweepHandle.unref?.();

View file

@ -0,0 +1,46 @@
// utils/pendingVpPinResets.js
//
// In-memory store for pending voice portal PIN reset confirmation cards.
import { logger } from './logger.js';
const TTL_MS = 10 * 60 * 1000;
const SWEEP_INTERVAL_MS = 60 * 1000;
const _store = new Map();
export const pendingVpPinResets = {
set(cardId, data) {
_store.set(cardId, { ...data, timestamp: Date.now() });
return this;
},
get(cardId) {
return _store.get(cardId);
},
has(cardId) {
return _store.has(cardId);
},
delete(cardId) {
return _store.delete(cardId);
},
get size() {
return _store.size;
},
};
const sweepHandle = setInterval(() => {
const now = Date.now();
for (const [cardId, data] of _store.entries()) {
const stamped = typeof data?.timestamp === 'number' ? data.timestamp : 0;
if (now - stamped > TTL_MS) {
logger('resetvppin:cleanup', `Expired card ${cardId} for store ${data?.storeNum || 'unknown'}`);
_store.delete(cardId);
}
}
}, SWEEP_INTERVAL_MS);
sweepHandle.unref?.();