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>
140 lines
4.9 KiB
JavaScript
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;
|
|
}
|