collabSupport/services/callTest/config.js
jmcqueen 1eaabbcee1 Add /voicereport store digest and harden Webex CDR feed handling.
Groups cdr_feed legs by Correlation ID for call-level summaries, fixes report-column field parsing and Docker proxy routing, and adds /calltest post-call Twilio and CDR enrichment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:43:11 -04:00

140 lines
4.9 KiB
JavaScript

// services/callTest/config.js
// Env defaults + optional per-store entry overrides from config/calltest-stores.json.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STORES_CONFIG_PATH = path.resolve(__dirname, '../../config/calltest-stores.json');
let _storeOverridesCache = null;
function envInt(name, fallback) {
const raw = process.env[name];
if (raw == null || raw === '') return fallback;
const n = Number(raw);
return Number.isFinite(n) ? n : fallback;
}
function envStr(name, fallback) {
const raw = process.env[name];
return raw != null && String(raw).trim() ? String(raw).trim() : fallback;
}
export function getWebhookBaseUrl() {
const base = envStr('TWILIO_WEBHOOK_BASE_URL', '');
return base.replace(/\/+$/, '');
}
export function loadStoreOverrides() {
if (_storeOverridesCache) return _storeOverridesCache;
try {
if (!fs.existsSync(STORES_CONFIG_PATH)) {
_storeOverridesCache = {};
return _storeOverridesCache;
}
const raw = fs.readFileSync(STORES_CONFIG_PATH, 'utf8');
const parsed = JSON.parse(raw);
_storeOverridesCache = parsed && typeof parsed === 'object' ? parsed : {};
return _storeOverridesCache;
} catch {
_storeOverridesCache = {};
return _storeOverridesCache;
}
}
/** @param {string|null} storeNum */
export function getCallTestConfig(storeNum = null) {
const overrides = storeNum ? (loadStoreOverrides()[String(storeNum)] || {}) : {};
return {
dtmf: String(overrides.dtmf ?? envStr('CALLTEST_DTMF', '1')),
greetingPauseSec: Number(overrides.greetingPauseSec ?? envInt('CALLTEST_GREETING_PAUSE_SEC', 5)),
answerWaitSec: Number(overrides.answerWaitSec ?? envInt('CALLTEST_ANSWER_WAIT_SEC', 45)),
dialTimeoutSec: envInt('CALLTEST_DIAL_TIMEOUT_SEC', 30),
listenSec: envInt('CALLTEST_LISTEN_SEC', 60),
introTts: envStr(
'CALLTEST_INTRO_TTS',
'Hello. This is an automated connectivity test from Collab Support. We will listen on this line for sixty seconds. Please stay on the line.',
),
outroTts: envStr(
'CALLTEST_OUTRO_TTS',
'Thank you. The connectivity test is complete. Goodbye.',
),
cdrEnrich: (() => {
const v = String(process.env.CALLTEST_CDR_ENRICH ?? 'true').toLowerCase();
return v === 'true' || v === '1' || v === 'yes';
})(),
cdrDelayMs: envInt('CALLTEST_CDR_DELAY_MS', 360_000),
cdrMatchBufferMs: envInt('CALLTEST_CDR_MATCH_BUFFER_MS', 120_000),
twilioEnrich: (() => {
const v = String(process.env.CALLTEST_TWILIO_ENRICH ?? 'true').toLowerCase();
return v === 'true' || v === '1' || v === 'yes';
})(),
twilioDetailsDelayMs: envInt('CALLTEST_TWILIO_DETAILS_DELAY_MS', 10_000),
twilioInsightsDelayMs: envInt('CALLTEST_TWILIO_INSIGHTS_DELAY_MS', 360_000),
twilioInsightsRetries: envInt('CALLTEST_TWILIO_INSIGHTS_RETRIES', 3),
twilioInsightsRetryIntervalMs: envInt('CALLTEST_TWILIO_INSIGHTS_RETRY_MS', 30_000),
};
}
export function buildVoiceWebhookUrl(testId, mode, step) {
const base = getWebhookBaseUrl();
const q = new URLSearchParams({ testId, mode, step });
return `${base}/twilio/calltest/voice?${q.toString()}`;
}
export function buildStatusWebhookUrl(testId) {
const base = getWebhookBaseUrl();
const q = new URLSearchParams({ testId });
return `${base}/twilio/calltest/status?${q.toString()}`;
}
/**
* Exact webhook URL Twilio was given for this inbound request. Uses the same
* builders as outbound call setup / TwiML redirects so proxy path stripping
* (e.g. NGINX /CollabSupport/ → backend) cannot desync validation.
*/
export function resolveCallTestWebhookUrl(req) {
const routePath = req.path || req.originalUrl?.split('?')[0] || '';
if (routePath.endsWith('/twilio/calltest/voice')) {
const testId = String(req.query?.testId || '').trim();
const mode = req.query?.mode === 'store' ? 'store' : 'dial';
const step = String(req.query?.step || '').trim();
if (!testId || !step) return null;
return buildVoiceWebhookUrl(testId, mode, step);
}
if (routePath.endsWith('/twilio/calltest/status')) {
const testId = String(req.query?.testId || '').trim();
if (!testId) return null;
return buildStatusWebhookUrl(testId);
}
return null;
}
/** Normalize user input to E.164 (+1...) */
export function normalizeE164(input) {
if (!input) return null;
let s = String(input).trim().replace(/[^\d+]/g, '');
if (s.startsWith('+')) {
const digits = s.slice(1);
if (!/^\d{10,15}$/.test(digits)) return null;
return `+${digits}`;
}
const digits = s.replace(/\D/g, '');
if (digits.length === 10) return `+1${digits}`;
if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
return null;
}
export function isStoreNumberToken(token) {
return /^\d{2,4}$/.test(String(token || '').trim());
}
export function _resetStoreOverridesCacheForTests() {
_storeOverridesCache = null;
}