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>
255 lines
7.1 KiB
JavaScript
255 lines
7.1 KiB
JavaScript
// services/callTest/locationResolver.js
|
|
// Resolve Webex Calling location from a dialed E.164 (store main / AA DID).
|
|
|
|
import { logger } from '../../utils/logger.js';
|
|
import { normalizeE164 } from './config.js';
|
|
|
|
const LOG_SCOPE = 'calltest:location';
|
|
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
|
|
let _numbersCache = null;
|
|
let _numbersCacheAt = 0;
|
|
|
|
async function getWebex() {
|
|
const mod = await import('../../integrations/webex/WebexClient.js');
|
|
return mod.default;
|
|
}
|
|
|
|
function parseLinkNext(linkHeader) {
|
|
if (!linkHeader || typeof linkHeader !== 'string') return null;
|
|
for (const part of linkHeader.split(',')) {
|
|
const m = part.match(/<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i);
|
|
if (m) return m[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function phoneDigits(value) {
|
|
const d = String(value || '').replace(/\D/g, '');
|
|
if (d.length === 10) return `1${d}`;
|
|
if (d.length === 11 && d.startsWith('1')) return d;
|
|
return d;
|
|
}
|
|
|
|
function numberRecordFromApi(n) {
|
|
const raw = n.phoneNumber || n.number || n.value;
|
|
const loc = n.location;
|
|
if (!raw || !loc?.id) return null;
|
|
return {
|
|
locationId: loc.id,
|
|
locationName: loc.name || null,
|
|
phoneNumber: raw,
|
|
extension: n.extension || n.primaryExtension || null,
|
|
owner: n.owner || null,
|
|
raw: n,
|
|
};
|
|
}
|
|
|
|
async function fetchAllNumbers() {
|
|
const webex = await getWebex();
|
|
const all = [];
|
|
let nextUrl = null;
|
|
let data;
|
|
let headers;
|
|
|
|
({ data, headers } = await webex.requestRaw('GET', 'telephony/config/numbers', null, { max: 1000 }));
|
|
const batch = data?.phoneNumbers || [];
|
|
all.push(...batch);
|
|
nextUrl = parseLinkNext(headers?.link || headers?.Link);
|
|
|
|
while (nextUrl) {
|
|
({ data, headers } = await webex.requestRaw('GET', nextUrl));
|
|
all.push(...(data?.phoneNumbers || []));
|
|
nextUrl = parseLinkNext(headers?.link || headers?.Link);
|
|
}
|
|
|
|
return all;
|
|
}
|
|
|
|
async function buildNumbersCache() {
|
|
const now = Date.now();
|
|
if (_numbersCache && now - _numbersCacheAt < CACHE_TTL_MS) {
|
|
return _numbersCache;
|
|
}
|
|
|
|
logger(LOG_SCOPE, 'Building location phone index from telephony/config/numbers', 'debug');
|
|
const numbers = await fetchAllNumbers();
|
|
const byPhoneDigits = new Map();
|
|
const byPersonId = new Map();
|
|
const byExtension = new Map();
|
|
const records = [];
|
|
|
|
for (const n of numbers) {
|
|
const entry = numberRecordFromApi(n);
|
|
if (!entry) continue;
|
|
records.push(entry);
|
|
|
|
const keys = new Set([phoneDigits(entry.phoneNumber), phoneDigits(normalizeE164(entry.phoneNumber) || entry.phoneNumber)]);
|
|
for (const k of keys) {
|
|
if (k) byPhoneDigits.set(k, entry);
|
|
}
|
|
|
|
const ownerId = entry.owner?.id;
|
|
if (ownerId) {
|
|
if (!byPersonId.has(ownerId)) byPersonId.set(ownerId, []);
|
|
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, byExtension, records };
|
|
_numbersCacheAt = now;
|
|
logger(
|
|
LOG_SCOPE,
|
|
`Phone index built: ${byPhoneDigits.size} phone keys, ${byPersonId.size} owners, ${byExtension.size} extensions from ${numbers.length} numbers`,
|
|
'debug',
|
|
);
|
|
return _numbersCache;
|
|
}
|
|
|
|
function addMatchKey(set, value) {
|
|
if (value == null || value === '') return;
|
|
const s = String(value).trim();
|
|
if (!s) return;
|
|
set.add(s.toLowerCase());
|
|
const digits = s.replace(/\D/g, '');
|
|
if (digits) {
|
|
set.add(digits);
|
|
if (digits.length >= 4) set.add(digits.slice(-4));
|
|
if (digits.length >= 5) set.add(digits.slice(-5));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build CDR match keys for a provisioned phone number record.
|
|
* @param {object} record from numberRecordFromApi
|
|
* @returns {Set<string>}
|
|
*/
|
|
export function buildMatchKeysForNumber(record) {
|
|
const keys = new Set();
|
|
if (!record) return keys;
|
|
addMatchKey(keys, record.phoneNumber);
|
|
addMatchKey(keys, normalizeE164(record.phoneNumber));
|
|
addMatchKey(keys, record.extension);
|
|
if (record.owner?.name) addMatchKey(keys, record.owner.name);
|
|
return keys;
|
|
}
|
|
|
|
/**
|
|
* Build CDR match keys for a Webex person + their owned numbers.
|
|
* @param {object} person
|
|
* @param {object[]} ownedNumbers
|
|
* @returns {Set<string>}
|
|
*/
|
|
export function buildMatchKeysForPerson(person, ownedNumbers = []) {
|
|
const keys = new Set();
|
|
if (!person) return keys;
|
|
|
|
addMatchKey(keys, person.displayName);
|
|
(person.emails || []).forEach((e) => addMatchKey(keys, e));
|
|
(person.phoneNumbers || []).forEach((p) => addMatchKey(keys, p?.value || p));
|
|
|
|
for (const record of ownedNumbers) {
|
|
for (const k of buildMatchKeysForNumber(record)) keys.add(k);
|
|
}
|
|
|
|
return keys;
|
|
}
|
|
|
|
/**
|
|
* Find Webex location for a dialed E.164 (e.g. store main on /calltest dial).
|
|
* @returns {Promise<{locationId: string, locationName: string, phoneNumber: string}|null>}
|
|
*/
|
|
export async function resolveLocationForDialNumber(dialNumber) {
|
|
const assignment = await resolveNumberAssignment(dialNumber);
|
|
if (!assignment?.locationName) return null;
|
|
return {
|
|
locationId: assignment.locationId,
|
|
locationName: assignment.locationName,
|
|
phoneNumber: assignment.phoneNumber,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Full number assignment details for a dialed E.164.
|
|
* @returns {Promise<object|null>}
|
|
*/
|
|
export async function resolveNumberAssignment(dialNumber) {
|
|
const e164 = normalizeE164(dialNumber);
|
|
if (!e164) return null;
|
|
|
|
try {
|
|
const cache = await buildNumbersCache();
|
|
const key = phoneDigits(e164);
|
|
return cache.byPhoneDigits.get(key) || null;
|
|
} catch (err) {
|
|
logger(LOG_SCOPE, `resolveNumberAssignment failed: ${err.message}`, 'warn');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve location from numbers owned by a person.
|
|
* @returns {Promise<{ locationId, locationName, ownedNumbers }|null>}
|
|
*/
|
|
export async function resolvePersonLocation(personId) {
|
|
if (!personId) return null;
|
|
try {
|
|
const cache = await buildNumbersCache();
|
|
const ownedNumbers = cache.byPersonId.get(personId) || [];
|
|
if (ownedNumbers.length > 0) {
|
|
const first = ownedNumbers[0];
|
|
return {
|
|
locationId: first.locationId,
|
|
locationName: first.locationName,
|
|
ownedNumbers,
|
|
};
|
|
}
|
|
return null;
|
|
} catch (err) {
|
|
logger(LOG_SCOPE, `resolvePersonLocation failed: ${err.message}`, 'warn');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use buildNumbersCache internally; kept for tests.
|
|
*/
|
|
export async function buildPhoneIndex() {
|
|
const cache = await buildNumbersCache();
|
|
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() {
|
|
_numbersCache = null;
|
|
_numbersCacheAt = 0;
|
|
}
|
|
|
|
export function _setNumbersCacheForTests(cache) {
|
|
_numbersCache = cache;
|
|
_numbersCacheAt = Date.now();
|
|
}
|