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>
This commit is contained in:
parent
7aa8c37d1d
commit
1eaabbcee1
36 changed files with 3131 additions and 115 deletions
28
.env.example
28
.env.example
|
|
@ -56,6 +56,8 @@ WEBEX_BOT_TOKEN=your-bot-token-here
|
|||
# - spark-admin:licenses_read (webexhost, findEmptyLocations)
|
||||
# - identity:tokens_read (offboarduser: list a user's authorizations)
|
||||
# - identity:tokens_write (offboarduser: revoke a user's authorizations)
|
||||
# - spark-admin:calling_cdr_read (/voicereport + /calltest CDR via cdr_feed)
|
||||
# - analytics:read_all (/voicereport Webex Reports API — Pro Pack)
|
||||
# The authorizing admin must also hold Full / User / Device Admin role for the
|
||||
# token-management calls to succeed.
|
||||
#
|
||||
|
|
@ -86,6 +88,12 @@ WEBEX_TOKENS_PATH=./config/webex-service-tokens.json
|
|||
# Optional override for the Webex API base URL (default https://webexapis.com/v1).
|
||||
# WEBEX_BASE_URL=https://webexapis.com/v1
|
||||
|
||||
# CDR feed (/cdr_feed) uses analytics-calling.webexapis.com — a different host than
|
||||
# webexapis.com. In Docker, HTTP(S)_PROXY may route analytics subdomains through a
|
||||
# broken proxy while webexapis.com is in NO_PROXY. CDR requests bypass proxy by default.
|
||||
# WEBEX_CDR_USE_PROXY=true # honor HTTP(S)_PROXY for cdr_feed (default: false)
|
||||
# WEBEX_CDR_ANALYTICS_BASES=https://analytics-calling.webexapis.com/v1,https://analytics.webexapis.com/v1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Palo Alto Prisma SD-WAN (formerly CloudGenix) — WAN metrics for /voicediag
|
||||
# and /phonestatus follow-up
|
||||
|
|
@ -493,12 +501,30 @@ DECT_RELAY_AGENT_TOKEN=
|
|||
# CALLTEST_INTRO_TTS=Hello. This is an automated connectivity test...
|
||||
# CALLTEST_OUTRO_TTS=Thank you. The connectivity test is complete. Goodbye.
|
||||
#
|
||||
# Store mode CDR follow-up (uses spark-admin:calling_cdr_read):
|
||||
# Post-call enrichment (separate Webex cards after the result):
|
||||
# CALLTEST_TWILIO_ENRICH=true
|
||||
# CALLTEST_TWILIO_DETAILS_DELAY_MS=10000
|
||||
# CALLTEST_TWILIO_INSIGHTS_DELAY_MS=360000
|
||||
# CALLTEST_TWILIO_INSIGHTS_RETRIES=3
|
||||
# CALLTEST_TWILIO_INSIGHTS_RETRY_MS=30000
|
||||
#
|
||||
# Webex CDR leg match (store + dial when dialed number is a known location DID):
|
||||
# CALLTEST_CDR_ENRICH=true
|
||||
# CALLTEST_CDR_DELAY_MS=360000
|
||||
# CALLTEST_CDR_MATCH_BUFFER_MS=120000
|
||||
#
|
||||
# Per-store entry overrides: config/calltest-stores.json
|
||||
# -----------------------------------------------------------------------------
|
||||
# /voicereport — daily voice digest (CDR + Media Quality + Prisma WAN)
|
||||
# -----------------------------------------------------------------------------
|
||||
# VOICEREPORT_BUSINESS_START_HOUR=9
|
||||
# VOICEREPORT_BUSINESS_END_HOUR=21
|
||||
# VOICEREPORT_API_LAG_MS=300000
|
||||
# VOICEREPORT_MAX_DETAIL_ROWS=25
|
||||
# VOICEREPORT_REPORT_POLL_MS=5000
|
||||
# VOICEREPORT_REPORT_POLL_MAX_MS=180000
|
||||
# WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY=
|
||||
# -----------------------------------------------------------------------------
|
||||
# Optional. Per-base collect() RPC timeout. Corporate proxies can make
|
||||
# DBS-210 reads slow; 15s is comfortable, 30s is generous.
|
||||
# DECT_COLLECT_TIMEOUT_MS=15000
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
startStoreTest,
|
||||
} from '../services/callTest/callTestService.js';
|
||||
import { parseCallTestArgs } from '../services/callTest/parseArgs.js';
|
||||
import { renderCallTestResultMarkdown } from '../services/renderers/callTestRenderer.js';
|
||||
import { renderCallTestResultMarkdown, renderCallTestEnrichmentStatusMarkdown } from '../services/renderers/callTestRenderer.js';
|
||||
import { extractRequester } from '../utils/requester.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ export async function handleCallTest(bot, trigger) {
|
|||
await bot.say('markdown', `No in-memory session for testId \`${parsed.testId}\` (expired or unknown).`);
|
||||
return;
|
||||
}
|
||||
await bot.say('markdown', renderCallTestResultMarkdown(session));
|
||||
await bot.say('markdown', renderCallTestResultMarkdown(session) + renderCallTestEnrichmentStatusMarkdown(session));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const SHORT_HELP = {
|
|||
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
|
||||
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
|
||||
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
|
||||
voicereport: 'Daily voice digest: CDR + Media Quality + Prisma WAN for a store',
|
||||
calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)',
|
||||
|
||||
// Jira
|
||||
|
|
@ -150,6 +151,28 @@ const LONG_HELP = {
|
|||
'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.',
|
||||
],
|
||||
},
|
||||
voicereport: {
|
||||
title: '/voicereport',
|
||||
usage: [
|
||||
'/voicereport <store>',
|
||||
'/voicereport <store> today',
|
||||
'/voicereport <store> YYYY-MM-DD',
|
||||
'/voicereport <store> --detail',
|
||||
],
|
||||
examples: [
|
||||
'/voicereport 782',
|
||||
'/voicereport 782 today',
|
||||
'/voicereport 782 2026-07-22',
|
||||
'/voicereport 782 --detail',
|
||||
],
|
||||
notes: [
|
||||
'Store-scoped daily voice digest for **9am–9pm local** (default: **yesterday**). `today` uses 9am → 5 minutes ago.',
|
||||
'Pulls **all CDR legs** for the Webex location, **Calling Media Quality** report (Pro Pack + `analytics:read_all`), and **Prisma Webex_Calling_RTP** WAN overlay when configured.',
|
||||
'Requires `spark-admin:calling_cdr_read` + Control Hub role **Webex Calling Detailed Call History API access** for CDR.',
|
||||
'Report generation may take 1–3 minutes; an ack message posts first.',
|
||||
'HTTP: `?storeNum=782&date=yesterday&detail=true`.',
|
||||
],
|
||||
},
|
||||
calltest: {
|
||||
title: '/calltest',
|
||||
usage: [
|
||||
|
|
@ -169,7 +192,8 @@ const LONG_HELP = {
|
|||
'**Store path:** dials the store main number (Webex `locationMainNumber`), pauses for the AA greeting, sends DTMF `1`, waits for the store leg, then runs the shared **60-second** listen test (intro → pause → thank you).',
|
||||
'**Direct dial:** calls any E.164 number; on answer, runs the same 60s test with no AA/DTMF.',
|
||||
'Requires `TWILIO_*` env vars, `TWILIO_WEBHOOK_BASE_URL` (public HTTPS), and `CALLTEST_ENABLED=true`.',
|
||||
'Store tests optionally post a **CDR follow-up** ~6 minutes later when `CALLTEST_CDR_ENRICH=true`.',
|
||||
'After the result card, follow-ups post separately: **Twilio details** (~10s), **Twilio Insights** (~6 min, requires Advanced Features), **Webex CDR match** (~6 min when location is known).',
|
||||
'CDR match works for store tests and dial tests to a known store main number; legs are matched on **destination (called number)** only.',
|
||||
'Per-store entry tuning: `config/calltest-stores.json` (`dtmf`, `greetingPauseSec`, `answerWaitSec`).',
|
||||
],
|
||||
},
|
||||
|
|
@ -277,7 +301,7 @@ const LONG_HELP = {
|
|||
|
||||
const GROUPS = [
|
||||
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
|
||||
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'calltest'] },
|
||||
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'voicereport', 'calltest'] },
|
||||
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
|
||||
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
|
||||
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js';
|
|||
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
|
||||
import { handleTestDevicesByModel } from './testDevicesByModel.js';
|
||||
import { handleCallTest } from './callTest.js';
|
||||
import { handleVoiceReport } from './voiceReport.js';
|
||||
|
||||
/**
|
||||
* Each entry:
|
||||
|
|
@ -57,6 +58,7 @@ export const commands = [
|
|||
// (mutating: true). See commands/voiceDiag.js + services/voiceDiag/
|
||||
// for the full behavior contract + required scopes.
|
||||
{ name: 'voicediag', handler: handleVoiceDiag, mutating: true },
|
||||
{ name: 'voicereport', handler: handleVoiceReport, mutating: false },
|
||||
{ name: 'wohistory', handler: handleWoHistory, mutating: false },
|
||||
{ name: 'wosummary', handler: handleWoSummary, mutating: false },
|
||||
{ name: 'woattachments', handler: handleWoAttachments, mutating: false },
|
||||
|
|
|
|||
64
commands/voiceReport.js
Normal file
64
commands/voiceReport.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// commands/voiceReport.js
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { collectVoiceReport } from '../services/voiceReport/voiceReportService.js';
|
||||
import { renderVoiceReportMarkdown } from '../services/renderers/voiceReportRenderer.js';
|
||||
|
||||
function pickDateArg(args) {
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const a = String(args[i] || '').trim();
|
||||
if (!a || a.startsWith('--')) continue;
|
||||
if (/^\d{2,4}$/.test(a) && i === 1) continue;
|
||||
return a;
|
||||
}
|
||||
const flag = args.find((a) => String(a).startsWith('--date='));
|
||||
if (flag) return flag.split('=').slice(1).join('=');
|
||||
const idx = args.indexOf('--date');
|
||||
if (idx >= 0 && args[idx + 1]) return args[idx + 1];
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasDetailFlag(args, query) {
|
||||
return args.some((a) => ['detail', 'detailed', '--detail', '--detailed'].includes(String(a).toLowerCase()))
|
||||
|| query.detail === 'true'
|
||||
|| query.detailed === 'true';
|
||||
}
|
||||
|
||||
export async function handleVoiceReport(bot, trigger) {
|
||||
logger('voicereport', 'Handler entered', 'debug');
|
||||
|
||||
const query = trigger.query || {};
|
||||
const args = trigger.args || [];
|
||||
|
||||
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
|
||||
const dateArg = pickDateArg(args) || query.date || null;
|
||||
const detail = hasDetailFlag(args, query);
|
||||
|
||||
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
|
||||
await bot.say(
|
||||
'markdown',
|
||||
'**Voice report usage**\n\n' +
|
||||
'`/voicereport <store>` — yesterday 9am–9pm local (default)\n' +
|
||||
'`/voicereport <store> today` — today 9am → 5 min ago\n' +
|
||||
'`/voicereport <store> 2026-07-22` — specific day\n' +
|
||||
'`/voicereport <store> --detail` — include per-call table\n\n' +
|
||||
'HTTP: `?storeNum=782&date=yesterday&detail=true`',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await bot.say(
|
||||
'markdown',
|
||||
`**Voice report** — generating for store **${storeNum}** ` +
|
||||
`(${dateArg || 'yesterday'})… This may take 1–3 minutes while Webex builds the Media Quality report.`,
|
||||
);
|
||||
|
||||
try {
|
||||
const report = await collectVoiceReport(storeNum, { dateArg });
|
||||
const md = renderVoiceReportMarkdown(report, { detail });
|
||||
await bot.say('markdown', md);
|
||||
} catch (err) {
|
||||
logger('voicereport', `Failed for store ${storeNum}: ${err.message}`, 'error');
|
||||
await bot.say('markdown', `❌ Voice report failed for store ${storeNum}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -413,7 +413,7 @@ function appMetricsPath() {
|
|||
* shipping default for WAN checks
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 1440) {
|
||||
export async function getAppMetric(siteId, appId, metricKey, windowOrOpts = 1440) {
|
||||
const spec = APP_METRIC_NAMES[metricKey];
|
||||
if (!spec) {
|
||||
throw new Error(
|
||||
|
|
@ -427,6 +427,27 @@ export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 144
|
|||
return null;
|
||||
}
|
||||
|
||||
let startTime;
|
||||
let endTime;
|
||||
let windowMinutes;
|
||||
if (
|
||||
windowOrOpts
|
||||
&& typeof windowOrOpts === 'object'
|
||||
&& windowOrOpts.startTime
|
||||
&& windowOrOpts.endTime
|
||||
) {
|
||||
startTime = windowOrOpts.startTime;
|
||||
endTime = windowOrOpts.endTime;
|
||||
windowMinutes = Math.max(
|
||||
1,
|
||||
Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 60_000),
|
||||
);
|
||||
} else {
|
||||
windowMinutes = typeof windowOrOpts === 'number' ? windowOrOpts : 1440;
|
||||
startTime = windowStart(windowMinutes);
|
||||
endTime = nowIso();
|
||||
}
|
||||
|
||||
const filter = {
|
||||
site: [String(siteId)],
|
||||
app: [String(appId)],
|
||||
|
|
@ -436,8 +457,8 @@ export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 144
|
|||
|
||||
try {
|
||||
const res = await paloAltoAxios.post(appMetricsPath(), {
|
||||
start_time: windowStart(windowMinutes),
|
||||
end_time: nowIso(),
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
// NOT pickInterval — see pickAppMetricInterval doc for why:
|
||||
// app metrics need the fine-grained series or the client-side
|
||||
// worst-window aggregation is meaningless.
|
||||
|
|
|
|||
|
|
@ -82,9 +82,162 @@ export async function createOutboundCall({ to, voiceUrl, statusCallback, timeout
|
|||
return call;
|
||||
}
|
||||
|
||||
function twilioAxios() {
|
||||
const { accountSid, authToken } = twilioAuth();
|
||||
return {
|
||||
accountSid,
|
||||
client: axios.create({
|
||||
auth: { username: accountSid, password: authToken },
|
||||
timeout: 30_000,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function unavailable(reason, extra = {}) {
|
||||
return { available: false, reason, data: null, ...extra };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the Programmable Voice Call resource (duration, price, timestamps).
|
||||
*/
|
||||
export async function fetchCallResource(callSid) {
|
||||
if (!callSid) return unavailable('no callSid');
|
||||
try {
|
||||
const { accountSid, client } = twilioAxios();
|
||||
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls/${callSid}.json`;
|
||||
const resp = await client.get(url);
|
||||
const d = resp.data || {};
|
||||
return {
|
||||
available: true,
|
||||
data: {
|
||||
sid: d.sid,
|
||||
status: d.status,
|
||||
duration: d.duration != null ? Number(d.duration) : null,
|
||||
startTime: d.start_time || d.date_created,
|
||||
endTime: d.end_time,
|
||||
from: d.from,
|
||||
to: d.to,
|
||||
direction: d.direction,
|
||||
price: d.price,
|
||||
priceUnit: d.price_unit,
|
||||
answeredBy: d.answered_by,
|
||||
forwardedFrom: d.forwarded_from,
|
||||
callerName: d.caller_name,
|
||||
queueTime: d.queue_time,
|
||||
},
|
||||
raw: d,
|
||||
};
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const msg = err.response?.data?.message || err.message;
|
||||
logger(LOG_SCOPE, `fetchCallResource ${callSid} failed: ${msg}`, 'warn');
|
||||
return unavailable(status ? `HTTP ${status}: ${msg}` : msg, { status });
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInsightsUrl(path) {
|
||||
const { client } = twilioAxios();
|
||||
const url = `https://insights.twilio.com/v1/Voice/${path}`;
|
||||
const resp = await client.get(url);
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice Insights call summary (requires Advanced Features on the account).
|
||||
*/
|
||||
export async function fetchCallInsightsSummary(callSid) {
|
||||
if (!callSid) return unavailable('no callSid');
|
||||
try {
|
||||
const data = await fetchInsightsUrl(`${callSid}/Summary`);
|
||||
return {
|
||||
available: true,
|
||||
data: {
|
||||
callSid: data.call_sid || callSid,
|
||||
callState: data.call_state,
|
||||
processingState: data.processing_state,
|
||||
startTime: data.start_time,
|
||||
endTime: data.end_time,
|
||||
duration: data.duration,
|
||||
connectDuration: data.connect_duration,
|
||||
from: data.from,
|
||||
to: data.to,
|
||||
carrierEdge: data.carrier_edge,
|
||||
sdkEdge: data.sdk_edge,
|
||||
tags: data.tags,
|
||||
attributes: data.attributes,
|
||||
properties: data.properties,
|
||||
},
|
||||
raw: data,
|
||||
};
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const msg = err.response?.data?.message || err.message;
|
||||
if (status === 404 || status === 403) {
|
||||
return unavailable(
|
||||
status === 403
|
||||
? 'Voice Insights Advanced Features may not be enabled'
|
||||
: 'Insights summary not ready or not found',
|
||||
{ status, retryable: status === 404 },
|
||||
);
|
||||
}
|
||||
logger(LOG_SCOPE, `fetchCallInsightsSummary ${callSid} failed: ${msg}`, 'warn');
|
||||
return unavailable(status ? `HTTP ${status}: ${msg}` : msg, { status });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice Insights per-call metrics (carrier_edge for PSTN outbound).
|
||||
*/
|
||||
export async function fetchCallInsightsMetrics(callSid, edge = 'carrier_edge') {
|
||||
if (!callSid) return unavailable('no callSid');
|
||||
try {
|
||||
const data = await fetchInsightsUrl(`${callSid}/Metrics?Edge=${encodeURIComponent(edge)}`);
|
||||
const metrics = Array.isArray(data.metrics) ? data.metrics : [];
|
||||
const highlights = { maxJitter: null, maxPacketLoss: null, minMos: null };
|
||||
for (const m of metrics) {
|
||||
const name = String(m.name || '').toLowerCase();
|
||||
const vals = m.values || m.samples || [];
|
||||
for (const v of vals) {
|
||||
const num = Number(v?.value ?? v?.avg ?? v);
|
||||
if (!Number.isFinite(num)) continue;
|
||||
if (name.includes('jitter') && (highlights.maxJitter == null || num > highlights.maxJitter)) {
|
||||
highlights.maxJitter = num;
|
||||
}
|
||||
if (name.includes('packet') && name.includes('loss') && (highlights.maxPacketLoss == null || num > highlights.maxPacketLoss)) {
|
||||
highlights.maxPacketLoss = num;
|
||||
}
|
||||
if (name.includes('mos') && (highlights.minMos == null || num < highlights.minMos)) {
|
||||
highlights.minMos = num;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
data: { edge, metrics, highlights },
|
||||
raw: data,
|
||||
};
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const msg = err.response?.data?.message || err.message;
|
||||
if (status === 404 || status === 403) {
|
||||
return unavailable(
|
||||
status === 403
|
||||
? 'Voice Insights Advanced Features may not be enabled'
|
||||
: 'Insights metrics not ready or not found',
|
||||
{ status, retryable: status === 404 },
|
||||
);
|
||||
}
|
||||
logger(LOG_SCOPE, `fetchCallInsightsMetrics ${callSid} failed: ${msg}`, 'warn');
|
||||
return unavailable(status ? `HTTP ${status}: ${msg}` : msg, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
isTwilioConfigured,
|
||||
isCallTestEnabled,
|
||||
createOutboundCall,
|
||||
validateTwilioSignature,
|
||||
fetchCallResource,
|
||||
fetchCallInsightsSummary,
|
||||
fetchCallInsightsMetrics,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -51,6 +51,37 @@ class WebexClient {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP to analytics-calling / analytics.webexapis.com (cdr_feed, etc.).
|
||||
* Bypasses HTTP(S)_PROXY by default — axios proxy-from-env often sends analytics
|
||||
* subdomains through a corp proxy while webexapis.com is listed in NO_PROXY.
|
||||
* Set WEBEX_CDR_USE_PROXY=true to honor the process proxy instead.
|
||||
*/
|
||||
async analyticsRequestRaw(method, url, { data = null, params = null, timeout = 15000 } = {}) {
|
||||
const useProxy = String(process.env.WEBEX_CDR_USE_PROXY || '').toLowerCase() === 'true';
|
||||
const token = await this.auth.getAccessToken();
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
method,
|
||||
url,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data,
|
||||
params,
|
||||
timeout,
|
||||
proxy: useProxy ? undefined : false,
|
||||
});
|
||||
return { data: response.data, headers: response.headers, status: response.status };
|
||||
} catch (err) {
|
||||
if (err.response?.status === 401) {
|
||||
logger('webex:client', '401 on analytics host — forcing token refresh', 'warn');
|
||||
await this.auth.forceRefresh();
|
||||
return this.analyticsRequestRaw(method, url, { data, params, timeout });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience wrappers
|
||||
async getMe() {
|
||||
return this.request('GET', 'people/me');
|
||||
|
|
|
|||
177
integrations/webex/reportsClient.js
Normal file
177
integrations/webex/reportsClient.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// integrations/webex/reportsClient.js
|
||||
// Webex Control Hub Reports API (Pro Pack) — Calling Media Quality.
|
||||
|
||||
import axios from 'axios';
|
||||
import AdmZip from 'adm-zip';
|
||||
import webex from './WebexClient.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import {
|
||||
filterMediaQualityRows,
|
||||
parseMediaQualityCsv,
|
||||
reportDateRangeFromWindow,
|
||||
} from './reportsCsv.js';
|
||||
|
||||
export { filterMediaQualityRows, parseMediaQualityCsv, reportDateRangeFromWindow } from './reportsCsv.js';
|
||||
|
||||
const LOG_SCOPE = 'webex:reports';
|
||||
const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let _templateCache = null;
|
||||
let _templateCacheAt = 0;
|
||||
|
||||
function envInt(name, fallback) {
|
||||
const v = Number(process.env[name]);
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
}
|
||||
|
||||
function pollIntervalMs() {
|
||||
return envInt('VOICEREPORT_REPORT_POLL_MS', 5000);
|
||||
}
|
||||
|
||||
function pollMaxMs() {
|
||||
return envInt('VOICEREPORT_REPORT_POLL_MAX_MS', 180_000);
|
||||
}
|
||||
|
||||
export function _clearReportTemplateCacheForTests() {
|
||||
_templateCache = null;
|
||||
_templateCacheAt = 0;
|
||||
}
|
||||
|
||||
export async function listReportTemplates() {
|
||||
const now = Date.now();
|
||||
if (_templateCache && now - _templateCacheAt < TEMPLATE_CACHE_TTL_MS) {
|
||||
return _templateCache;
|
||||
}
|
||||
const data = await webex.request('GET', 'reports/templates');
|
||||
const items = data?.items || data?.reportTemplates || [];
|
||||
_templateCache = items;
|
||||
_templateCacheAt = now;
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve template id by env override or fuzzy name match.
|
||||
*/
|
||||
export async function resolveMediaQualityTemplateId() {
|
||||
const override = process.env.WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY;
|
||||
if (override && String(override).trim()) {
|
||||
return Number(override);
|
||||
}
|
||||
const templates = await listReportTemplates();
|
||||
const hit = templates.find((t) => {
|
||||
const name = String(t.name || t.title || '').toLowerCase();
|
||||
return name.includes('media quality') || name.includes('calling media');
|
||||
});
|
||||
return hit ? Number(hit.id ?? hit.templateId) : null;
|
||||
}
|
||||
|
||||
export async function createReport(templateId, { startDate, endDate, siteList } = {}) {
|
||||
if (!templateId || !Number.isFinite(templateId)) {
|
||||
return { ok: false, reason: 'no Media Quality report template id (set WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY)' };
|
||||
}
|
||||
const body = {
|
||||
templateId: Number(templateId),
|
||||
};
|
||||
if (startDate) body.startDate = startDate;
|
||||
if (endDate) body.endDate = endDate;
|
||||
if (siteList) body.siteList = siteList;
|
||||
|
||||
try {
|
||||
const data = await webex.request('POST', 'reports', body);
|
||||
return { ok: true, report: data };
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.message || err.message;
|
||||
logger(LOG_SCOPE, `createReport failed: ${msg}`, 'warn');
|
||||
return { ok: false, reason: msg };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReport(reportId) {
|
||||
return webex.request('GET', `reports/${reportId}`);
|
||||
}
|
||||
|
||||
export async function pollReportUntilDone(reportId) {
|
||||
const deadline = Date.now() + pollMaxMs();
|
||||
while (Date.now() < deadline) {
|
||||
const report = await getReport(reportId);
|
||||
const status = String(report?.status || '').toLowerCase();
|
||||
if (status === 'done' || status === 'complete' || status === 'completed') {
|
||||
return { ok: true, report };
|
||||
}
|
||||
if (status === 'failed' || status === 'error') {
|
||||
return { ok: false, reason: report?.statusMessage || 'report generation failed', report };
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs()));
|
||||
}
|
||||
return { ok: false, reason: 'report poll timed out' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download report payload (CSV text or first CSV inside ZIP).
|
||||
*/
|
||||
export async function downloadReportText(downloadURL) {
|
||||
if (!downloadURL) return { ok: false, reason: 'no downloadURL' };
|
||||
const token = await webex.auth.getAccessToken();
|
||||
const resp = await axios.get(downloadURL, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 60_000,
|
||||
maxContentLength: 50 * 1024 * 1024,
|
||||
});
|
||||
const buf = Buffer.from(resp.data);
|
||||
if (buf[0] === 0x50 && buf[1] === 0x4b) {
|
||||
return extractCsvFromZip(buf);
|
||||
}
|
||||
return { ok: true, text: buf.toString('utf8') };
|
||||
}
|
||||
|
||||
function extractCsvFromZip(buf) {
|
||||
try {
|
||||
const zip = new AdmZip(buf);
|
||||
const entry = zip.getEntries().find((e) => /\.csv$/i.test(e.entryName));
|
||||
if (!entry) return { ok: false, reason: 'no CSV inside report ZIP' };
|
||||
return { ok: true, text: entry.getData().toString('utf8') };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: `ZIP extract failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaQualityReport({ window, locationName }) {
|
||||
const templateId = await resolveMediaQualityTemplateId();
|
||||
if (!templateId) {
|
||||
return { available: false, reason: 'Media Quality template not found' };
|
||||
}
|
||||
|
||||
const { startDate, endDate } = reportDateRangeFromWindow(window);
|
||||
const created = await createReport(templateId, { startDate, endDate });
|
||||
if (!created.ok) {
|
||||
return { available: false, reason: created.reason };
|
||||
}
|
||||
|
||||
const reportId = created.report?.id;
|
||||
if (!reportId) {
|
||||
return { available: false, reason: 'create report returned no id' };
|
||||
}
|
||||
|
||||
const polled = await pollReportUntilDone(reportId);
|
||||
if (!polled.ok) {
|
||||
return { available: false, reason: polled.reason };
|
||||
}
|
||||
|
||||
const downloadURL = polled.report?.downloadURL || polled.report?.downloadUrl;
|
||||
const dl = await downloadReportText(downloadURL);
|
||||
if (!dl.ok) {
|
||||
return { available: false, reason: dl.reason };
|
||||
}
|
||||
|
||||
const parsed = parseMediaQualityCsv(dl.text);
|
||||
const filtered = filterMediaQualityRows(parsed.rows, { locationName, window });
|
||||
|
||||
return {
|
||||
available: true,
|
||||
reportId,
|
||||
rawCount: parsed.rows.length,
|
||||
rows: filtered,
|
||||
headers: parsed.headers,
|
||||
};
|
||||
}
|
||||
73
integrations/webex/reportsCsv.js
Normal file
73
integrations/webex/reportsCsv.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// integrations/webex/reportsCsv.js
|
||||
// Pure CSV helpers for Webex Reports (no Webex client import).
|
||||
|
||||
export function reportDateRangeFromWindow(window) {
|
||||
const startDay = String(window.startTime).slice(0, 10);
|
||||
const endDay = String(window.endTime).slice(0, 10);
|
||||
return { startDate: startDay, endDate: endDay };
|
||||
}
|
||||
|
||||
function splitCsvLine(line) {
|
||||
const out = [];
|
||||
let cur = '';
|
||||
let inQ = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i];
|
||||
if (c === '"') {
|
||||
inQ = !inQ;
|
||||
continue;
|
||||
}
|
||||
if (c === ',' && !inQ) {
|
||||
out.push(cur.trim());
|
||||
cur = '';
|
||||
continue;
|
||||
}
|
||||
cur += c;
|
||||
}
|
||||
out.push(cur.trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
function normKey(k) {
|
||||
return String(k || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
export function parseMediaQualityCsv(text) {
|
||||
if (!text || !String(text).trim()) return { headers: [], rows: [] };
|
||||
const lines = String(text).split(/\r?\n/).filter((l) => l.trim());
|
||||
if (!lines.length) return { headers: [], rows: [] };
|
||||
const headers = splitCsvLine(lines[0]);
|
||||
const rows = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = splitCsvLine(lines[i]);
|
||||
if (!cols.some(Boolean)) continue;
|
||||
const row = {};
|
||||
headers.forEach((h, idx) => { row[h] = cols[idx] ?? ''; });
|
||||
row._norm = Object.fromEntries(
|
||||
headers.map((h, idx) => [normKey(h), cols[idx] ?? '']),
|
||||
);
|
||||
rows.push(row);
|
||||
}
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
export function filterMediaQualityRows(rows, { locationName, window }) {
|
||||
const loc = String(locationName || '').toLowerCase();
|
||||
const startMs = new Date(window.startTime).getTime();
|
||||
const endMs = new Date(window.endTime).getTime();
|
||||
|
||||
return (rows || []).filter((row) => {
|
||||
const n = row._norm || {};
|
||||
const rowLoc = String(
|
||||
n.location || n.sitelocation || n.sitename || row.Location || '',
|
||||
).toLowerCase();
|
||||
if (loc && rowLoc && !rowLoc.includes(loc) && !loc.includes(rowLoc)) {
|
||||
return false;
|
||||
}
|
||||
const startRaw = n.starttime || n.callstarttime || n.start || row['Start Time'] || '';
|
||||
if (!startRaw) return true;
|
||||
const t = new Date(startRaw).getTime();
|
||||
if (!Number.isFinite(t)) return true;
|
||||
return t >= startMs && t <= endMs;
|
||||
});
|
||||
}
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
|
|
@ -8,12 +8,14 @@
|
|||
"name": "collabfinder",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.18",
|
||||
"async-mutex": "^0.5.0",
|
||||
"axios": "^1.13.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"form-data": "^4.0.5",
|
||||
"graphql-request": "^7.4.0",
|
||||
"luxon": "^3.7.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"twilio": "^6.0.2",
|
||||
"webex-node-bot-framework": "^2.5.1",
|
||||
|
|
@ -4517,6 +4519,15 @@
|
|||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz",
|
||||
"integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
|
|
@ -8687,6 +8698,15 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/makeerror": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@
|
|||
"test": "node --test tests/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.18",
|
||||
"async-mutex": "^0.5.0",
|
||||
"axios": "^1.13.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"form-data": "^4.0.5",
|
||||
"graphql-request": "^7.4.0",
|
||||
"luxon": "^3.7.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"twilio": "^6.0.2",
|
||||
"webex-node-bot-framework": "^2.5.1",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
isCoreStep,
|
||||
} from './twiml.js';
|
||||
import {
|
||||
renderCallTestCdrMarkdown,
|
||||
renderCallTestCdrMatchMarkdown,
|
||||
renderCallTestResultMarkdown,
|
||||
renderCallTestStartedMarkdown,
|
||||
} from '../renderers/callTestRenderer.js';
|
||||
|
|
@ -104,28 +104,17 @@ function computeResult(session) {
|
|||
}
|
||||
|
||||
async function scheduleCdrEnrichment(session) {
|
||||
const cfg = getCallTestConfig(session.storeNum);
|
||||
if (!cfg.cdrEnrich || session.mode !== 'store' || !session.personId) return;
|
||||
|
||||
const delay = cfg.cdrDelayMs;
|
||||
logger(LOG_SCOPE, `Scheduling CDR enrich for ${session.testId} in ${delay}ms`, 'debug');
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const { getHistoricalCallActivity } = await import('../phoneService.js');
|
||||
const cdr = await getHistoricalCallActivity(session.personId, 12, {
|
||||
locationName: session.locationName,
|
||||
const { scheduleCdrMatchEnrichment } = await import('./cdrMatcher.js');
|
||||
scheduleCdrMatchEnrichment(session, {
|
||||
notifyRoom,
|
||||
renderMarkdown: (s, result) => renderCallTestCdrMatchMarkdown(s, result),
|
||||
});
|
||||
const md = renderCallTestCdrMarkdown(session, cdr);
|
||||
await notifyRoom(session.roomId, md);
|
||||
} catch (err) {
|
||||
logger(LOG_SCOPE, `CDR enrich failed for ${session.testId}: ${err.message}`, 'warn');
|
||||
await notifyRoom(
|
||||
session.roomId,
|
||||
`**Call test CDR** (\`${session.testId}\`)\n\n_CDR fetch failed:_ ${err.message}`,
|
||||
);
|
||||
}
|
||||
}, delay).unref?.();
|
||||
|
||||
async function schedulePostCallEnrichment(session) {
|
||||
const { scheduleTwilioEnrichment } = await import('./twilioEnrichment.js');
|
||||
scheduleTwilioEnrichment(session, notifyRoom);
|
||||
await scheduleCdrEnrichment(session);
|
||||
}
|
||||
|
||||
async function finalizeSession(testId) {
|
||||
|
|
@ -145,10 +134,7 @@ async function finalizeSession(testId) {
|
|||
|
||||
if (next?.roomId) {
|
||||
await notifyRoom(next.roomId, renderCallTestResultMarkdown(next));
|
||||
}
|
||||
|
||||
if (next?.mode === 'store') {
|
||||
scheduleCdrEnrichment(next);
|
||||
await schedulePostCallEnrichment(next);
|
||||
}
|
||||
|
||||
return next;
|
||||
|
|
|
|||
214
services/callTest/cdrMatcher.js
Normal file
214
services/callTest/cdrMatcher.js
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// services/callTest/cdrMatcher.js
|
||||
// Narrow-window Webex CDR fetch + match the specific /calltest leg.
|
||||
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getCallTestConfig } from './config.js';
|
||||
import { getSession, updateSession } from './sessionStore.js';
|
||||
import {
|
||||
cdrCalledNumber,
|
||||
cdrCallingNumber,
|
||||
cdrDirectionValue,
|
||||
cdrDispositionValue,
|
||||
cdrDurationSeconds,
|
||||
cdrStartTime,
|
||||
} from '../cdrFeedParser.js';
|
||||
|
||||
const LOG_SCOPE = 'calltest:cdr';
|
||||
|
||||
const FIVE_MIN_MS = 5 * 60 * 1000;
|
||||
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute cdr_feed window from session events (Webex API rules applied).
|
||||
*/
|
||||
export function computeCdrWindow(session, bufferMs) {
|
||||
const events = session.events || {};
|
||||
const startMs = new Date(
|
||||
events.initiated || events.coreStartedAt || session.createdAt || Date.now(),
|
||||
).getTime() - bufferMs;
|
||||
|
||||
let endMs = new Date(events.completed || Date.now()).getTime() + bufferMs;
|
||||
|
||||
const latestAllowedEnd = Date.now() - FIVE_MIN_MS;
|
||||
if (endMs > latestAllowedEnd) endMs = latestAllowedEnd;
|
||||
|
||||
let start = startMs;
|
||||
if (endMs - start > TWELVE_HOURS_MS) {
|
||||
start = endMs - TWELVE_HOURS_MS;
|
||||
}
|
||||
if (start > endMs) {
|
||||
start = endMs - Math.min(TWELVE_HOURS_MS, 30 * 60 * 1000);
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: new Date(start).toISOString(),
|
||||
endTime: new Date(endMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Score CDR leg by destination (called number) matching the dialed test number.
|
||||
* Time proximity is only a tiebreaker when multiple legs hit the same DID.
|
||||
*/
|
||||
export function scoreCdrLeg(item, session) {
|
||||
const dialDigits = phoneDigits(session.dialNumber);
|
||||
if (!dialDigits) return 0;
|
||||
|
||||
const called = phoneDigits(cdrCalledNumber(item) || '');
|
||||
if (called !== dialDigits) return 0;
|
||||
|
||||
let score = 100;
|
||||
|
||||
const itemStart = new Date(cdrStartTime(item) || 0).getTime();
|
||||
const anchor = session.events?.initiated
|
||||
|| session.events?.answered
|
||||
|| session.events?.coreStartedAt;
|
||||
if (anchor && itemStart) {
|
||||
const delta = Math.abs(itemStart - new Date(anchor).getTime());
|
||||
if (delta < 120_000) score += 20;
|
||||
else if (delta < 300_000) score += 5;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function pickBestCdrLeg(items, session) {
|
||||
if (!items?.length) return null;
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
for (const item of items) {
|
||||
const s = scoreCdrLeg(item, session);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
best = item;
|
||||
}
|
||||
}
|
||||
if (!best || bestScore < 100) return null;
|
||||
return { leg: best, score: bestScore };
|
||||
}
|
||||
|
||||
export function formatCdrLeg(item) {
|
||||
if (!item) return null;
|
||||
return {
|
||||
start: cdrStartTime(item),
|
||||
direction: cdrDirectionValue(item),
|
||||
duration: cdrDurationSeconds(item),
|
||||
status: cdrDispositionValue(item),
|
||||
callingNumber: cdrCallingNumber(item),
|
||||
calledNumber: cdrCalledNumber(item),
|
||||
otherParty: item.otherParty || item.remoteParty,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveCdrContext(session) {
|
||||
if (session.locationName && session.personId) {
|
||||
return {
|
||||
personId: session.personId,
|
||||
locationName: session.locationName,
|
||||
};
|
||||
}
|
||||
if (session.locationName) {
|
||||
return { personId: session.personId || null, locationName: session.locationName };
|
||||
}
|
||||
if (session.mode === 'dial') {
|
||||
const { resolveLocationForDialNumber } = await import('./locationResolver.js');
|
||||
const loc = await resolveLocationForDialNumber(session.dialNumber);
|
||||
if (loc) {
|
||||
return { personId: null, locationName: loc.locationName, locationId: loc.locationId };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CDR and match the test call leg.
|
||||
*/
|
||||
export async function fetchMatchedCdrForSession(session) {
|
||||
const ctx = await resolveCdrContext(session);
|
||||
if (!ctx?.locationName) {
|
||||
return {
|
||||
available: false,
|
||||
reason: session.mode === 'dial'
|
||||
? 'dialed number is not a known Webex location main number'
|
||||
: 'no locationName on session',
|
||||
match: null,
|
||||
};
|
||||
}
|
||||
|
||||
const cfg = session.config || getCallTestConfig(session.storeNum);
|
||||
const { startTime, endTime } = computeCdrWindow(session, cfg.cdrMatchBufferMs);
|
||||
|
||||
const { getHistoricalCallActivity } = await import('../phoneService.js');
|
||||
const cdr = await getHistoricalCallActivity(ctx.personId, 12, {
|
||||
locationName: ctx.locationName,
|
||||
startTime,
|
||||
endTime,
|
||||
returnRawItems: true,
|
||||
skipPersonFilter: !ctx.personId,
|
||||
});
|
||||
|
||||
if (!cdr.available) {
|
||||
return { available: false, reason: cdr.reason, match: null, window: { startTime, endTime } };
|
||||
}
|
||||
|
||||
const items = cdr.rawItems || [];
|
||||
const picked = pickBestCdrLeg(items, session);
|
||||
|
||||
return {
|
||||
available: true,
|
||||
location: ctx.locationName,
|
||||
window: { startTime, endTime },
|
||||
rawCount: items.length,
|
||||
match: picked ? { ...formatCdrLeg(picked.leg), score: picked.score } : null,
|
||||
reason: picked ? null : `no leg matched (${items.length} records in window)`,
|
||||
};
|
||||
}
|
||||
|
||||
const _cdrTimers = new Map();
|
||||
|
||||
export function scheduleCdrMatchEnrichment(session, { notifyRoom, renderMarkdown }) {
|
||||
const cfg = session.config || getCallTestConfig(session.storeNum);
|
||||
if (!cfg.cdrEnrich || !session.roomId) return;
|
||||
|
||||
const minDelay = Math.max(cfg.cdrDelayMs, FIVE_MIN_MS + 30_000);
|
||||
if (_cdrTimers.has(session.testId)) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
_cdrTimers.delete(session.testId);
|
||||
const cur = getSession(session.testId);
|
||||
if (!cur) return;
|
||||
|
||||
try {
|
||||
logger(LOG_SCOPE, `CDR match fetch for testId=${session.testId}`, 'info');
|
||||
const result = await fetchMatchedCdrForSession(cur);
|
||||
updateSession(session.testId, {
|
||||
enrichment: {
|
||||
...(cur.enrichment || {}),
|
||||
cdr: { ...result, fetchedAt: new Date().toISOString() },
|
||||
},
|
||||
});
|
||||
await notifyRoom(session.roomId, renderMarkdown(getSession(session.testId), result));
|
||||
} catch (err) {
|
||||
logger(LOG_SCOPE, `CDR match failed for ${session.testId}: ${err.message}`, 'warn');
|
||||
await notifyRoom(
|
||||
session.roomId,
|
||||
`**Call test CDR** (\`${session.testId}\`)\n\n_CDR match failed:_ ${err.message}`,
|
||||
);
|
||||
}
|
||||
}, minDelay);
|
||||
|
||||
if (typeof timer.unref === 'function') timer.unref();
|
||||
_cdrTimers.set(session.testId, timer);
|
||||
}
|
||||
|
||||
export function _clearCdrTimersForTests() {
|
||||
for (const t of _cdrTimers.values()) clearTimeout(t);
|
||||
_cdrTimers.clear();
|
||||
}
|
||||
|
|
@ -67,6 +67,15 @@ export function getCallTestConfig(storeNum = null) {
|
|||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
116
services/callTest/locationResolver.js
Normal file
116
services/callTest/locationResolver.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// 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 _phoneIndex = null;
|
||||
let _phoneIndexAt = 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 buildPhoneIndex() {
|
||||
const now = Date.now();
|
||||
if (_phoneIndex && now - _phoneIndexAt < CACHE_TTL_MS) {
|
||||
return _phoneIndex;
|
||||
}
|
||||
|
||||
logger(LOG_SCOPE, 'Building location phone index from telephony/config/numbers', 'debug');
|
||||
const numbers = await fetchAllNumbers();
|
||||
const index = new Map();
|
||||
|
||||
for (const n of numbers) {
|
||||
const raw = n.phoneNumber || n.number || n.value;
|
||||
const loc = n.location;
|
||||
if (!raw || !loc?.id) continue;
|
||||
|
||||
const entry = {
|
||||
locationId: loc.id,
|
||||
locationName: loc.name || null,
|
||||
phoneNumber: raw,
|
||||
};
|
||||
|
||||
const keys = new Set([phoneDigits(raw), phoneDigits(normalizeE164(raw) || raw)]);
|
||||
for (const k of keys) {
|
||||
if (k) index.set(k, entry);
|
||||
}
|
||||
}
|
||||
|
||||
_phoneIndex = index;
|
||||
_phoneIndexAt = now;
|
||||
logger(LOG_SCOPE, `Phone index built: ${index.size} keys from ${numbers.length} numbers`, 'debug');
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 e164 = normalizeE164(dialNumber);
|
||||
if (!e164) return null;
|
||||
|
||||
try {
|
||||
const index = await buildPhoneIndex();
|
||||
const key = phoneDigits(e164);
|
||||
const hit = index.get(key);
|
||||
if (!hit?.locationName) return null;
|
||||
|
||||
return {
|
||||
locationId: hit.locationId,
|
||||
locationName: hit.locationName,
|
||||
phoneNumber: hit.phoneNumber,
|
||||
};
|
||||
} catch (err) {
|
||||
logger(LOG_SCOPE, `resolveLocationForDialNumber failed: ${err.message}`, 'warn');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function _clearLocationCacheForTests() {
|
||||
_phoneIndex = null;
|
||||
_phoneIndexAt = 0;
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ export function createSession(testId, data) {
|
|||
reachedCore: false,
|
||||
failedAt: null,
|
||||
callSid: null,
|
||||
enrichment: { twilio: {}, cdr: {} },
|
||||
...data,
|
||||
};
|
||||
_store.set(testId, session);
|
||||
|
|
|
|||
143
services/callTest/twilioEnrichment.js
Normal file
143
services/callTest/twilioEnrichment.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// services/callTest/twilioEnrichment.js
|
||||
// Post-call Twilio Call resource + Voice Insights enrichment.
|
||||
|
||||
import {
|
||||
fetchCallInsightsMetrics,
|
||||
fetchCallInsightsSummary,
|
||||
fetchCallResource,
|
||||
} from '../../integrations/twilio/client.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getCallTestConfig } from './config.js';
|
||||
import { getSession, updateSession } from './sessionStore.js';
|
||||
import {
|
||||
renderCallTestTwilioDetailsMarkdown,
|
||||
renderCallTestTwilioInsightsMarkdown,
|
||||
} from '../renderers/callTestRenderer.js';
|
||||
|
||||
const LOG_SCOPE = 'calltest:twilio-enrich';
|
||||
|
||||
const _detailTimers = new Map();
|
||||
const _insightTimers = new Map();
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function runDetailsEnrichment(testId, notifyRoom) {
|
||||
const session = getSession(testId);
|
||||
if (!session?.callSid || !session.roomId) return;
|
||||
|
||||
logger(LOG_SCOPE, `Fetching Twilio details for testId=${testId} callSid=${session.callSid}`, 'info');
|
||||
const details = await fetchCallResource(session.callSid);
|
||||
|
||||
const cur = getSession(testId);
|
||||
if (!cur) return;
|
||||
|
||||
updateSession(testId, {
|
||||
enrichment: {
|
||||
...(cur.enrichment || {}),
|
||||
twilio: {
|
||||
...(cur.enrichment?.twilio || {}),
|
||||
details,
|
||||
detailsFetchedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await notifyRoom(
|
||||
session.roomId,
|
||||
renderCallTestTwilioDetailsMarkdown(getSession(testId), details),
|
||||
);
|
||||
}
|
||||
|
||||
async function runInsightsEnrichment(testId, notifyRoom, cfg) {
|
||||
const session = getSession(testId);
|
||||
if (!session?.callSid || !session.roomId) return;
|
||||
|
||||
let summary = null;
|
||||
let metrics = null;
|
||||
let lastReason = null;
|
||||
|
||||
for (let attempt = 0; attempt <= cfg.twilioInsightsRetries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await sleep(cfg.twilioInsightsRetryIntervalMs);
|
||||
}
|
||||
|
||||
summary = await fetchCallInsightsSummary(session.callSid);
|
||||
metrics = await fetchCallInsightsMetrics(session.callSid);
|
||||
|
||||
const summaryOk = summary.available;
|
||||
const metricsOk = metrics.available;
|
||||
const retryable = summary.retryable || metrics.retryable;
|
||||
|
||||
if (summaryOk || metricsOk) break;
|
||||
lastReason = summary.reason || metrics.reason;
|
||||
if (!retryable) break;
|
||||
|
||||
logger(
|
||||
LOG_SCOPE,
|
||||
`Insights not ready for ${session.callSid} (attempt ${attempt + 1}/${cfg.twilioInsightsRetries + 1}): ${lastReason}`,
|
||||
'debug',
|
||||
);
|
||||
}
|
||||
|
||||
const insights = {
|
||||
available: summary?.available || metrics?.available || false,
|
||||
reason: lastReason,
|
||||
summary: summary?.available ? summary.data : null,
|
||||
metrics: metrics?.available ? metrics.data : null,
|
||||
summaryRaw: summary?.raw,
|
||||
metricsRaw: metrics?.raw,
|
||||
};
|
||||
|
||||
const cur = getSession(testId);
|
||||
if (!cur) return;
|
||||
|
||||
updateSession(testId, {
|
||||
enrichment: {
|
||||
...(cur.enrichment || {}),
|
||||
twilio: {
|
||||
...(cur.enrichment?.twilio || {}),
|
||||
insights,
|
||||
insightsFetchedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await notifyRoom(
|
||||
session.roomId,
|
||||
renderCallTestTwilioInsightsMarkdown(getSession(testId), insights),
|
||||
);
|
||||
}
|
||||
|
||||
export function scheduleTwilioEnrichment(session, notifyRoom) {
|
||||
const cfg = session.config || getCallTestConfig(session.storeNum);
|
||||
if (!cfg.twilioEnrich || !session.callSid || !session.roomId) return;
|
||||
|
||||
const testId = session.testId;
|
||||
|
||||
if (!_detailTimers.has(testId)) {
|
||||
const t = setTimeout(() => {
|
||||
_detailTimers.delete(testId);
|
||||
void runDetailsEnrichment(testId, notifyRoom);
|
||||
}, cfg.twilioDetailsDelayMs);
|
||||
if (typeof t.unref === 'function') t.unref();
|
||||
_detailTimers.set(testId, t);
|
||||
}
|
||||
|
||||
if (!_insightTimers.has(testId)) {
|
||||
const t = setTimeout(() => {
|
||||
_insightTimers.delete(testId);
|
||||
void runInsightsEnrichment(testId, notifyRoom, cfg);
|
||||
}, cfg.twilioInsightsDelayMs);
|
||||
if (typeof t.unref === 'function') t.unref();
|
||||
_insightTimers.set(testId, t);
|
||||
}
|
||||
}
|
||||
|
||||
export function _clearTwilioEnrichmentTimersForTests() {
|
||||
for (const t of _detailTimers.values()) clearTimeout(t);
|
||||
for (const t of _insightTimers.values()) clearTimeout(t);
|
||||
_detailTimers.clear();
|
||||
_insightTimers.clear();
|
||||
}
|
||||
132
services/cdrFeedParser.js
Normal file
132
services/cdrFeedParser.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// services/cdrFeedParser.js
|
||||
// Parse cdr_feed JSON payloads (field names vary by Webex release).
|
||||
|
||||
function normKey(key) {
|
||||
return String(key).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function indexCdrRecord(item) {
|
||||
if (!item || typeof item !== 'object') return {};
|
||||
const idx = {};
|
||||
for (const [k, v] of Object.entries(item)) {
|
||||
idx[normKey(k)] = v;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Read a CDR field by any known alias (camelCase or report column label). */
|
||||
export function cdrField(item, ...aliases) {
|
||||
if (!item) return null;
|
||||
const idx = indexCdrRecord(item);
|
||||
for (const alias of aliases) {
|
||||
const v = idx[normKey(alias)];
|
||||
if (v != null && v !== '') return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function cdrRecordId(item) {
|
||||
return cdrField(
|
||||
item,
|
||||
'reportId', 'Report ID', 'reportID',
|
||||
'callRecordingId',
|
||||
);
|
||||
}
|
||||
|
||||
export function cdrStartTime(item) {
|
||||
return cdrField(
|
||||
item,
|
||||
'startTime', 'start', 'callStartTime',
|
||||
'Start time', 'Start Time',
|
||||
'answerTime', 'Answer time', 'Answer Time',
|
||||
);
|
||||
}
|
||||
|
||||
export function cdrDurationSeconds(item) {
|
||||
const raw = cdrField(
|
||||
item,
|
||||
'durationSeconds', 'duration', 'callDuration', 'talkDuration', 'length',
|
||||
'Duration',
|
||||
);
|
||||
return Number(raw || 0);
|
||||
}
|
||||
|
||||
export function cdrDirectionValue(item) {
|
||||
return String(cdrField(
|
||||
item,
|
||||
'direction', 'callDirection', 'callType', 'directionIndicator',
|
||||
'Direction', 'Call direction', 'Call Direction',
|
||||
) || '').toUpperCase();
|
||||
}
|
||||
|
||||
export function cdrDispositionValue(item) {
|
||||
return String(cdrField(
|
||||
item,
|
||||
'status', 'result', 'callResult', 'callOutcome', 'callStatus',
|
||||
'Disposition', 'Answer indicator', 'Call outcome',
|
||||
) || 'unknown');
|
||||
}
|
||||
|
||||
export function cdrCallingNumber(item) {
|
||||
return cdrField(
|
||||
item,
|
||||
'callingNumber', 'callingParty', 'callingName',
|
||||
'Calling number', 'Calling Number',
|
||||
'User number', 'User Number',
|
||||
);
|
||||
}
|
||||
|
||||
export function cdrCalledNumber(item) {
|
||||
return cdrField(
|
||||
item,
|
||||
'calledNumber', 'calledParty', 'calledName',
|
||||
'Called number', 'Called Number',
|
||||
);
|
||||
}
|
||||
|
||||
export function cdrDedupKey(item) {
|
||||
const id = cdrRecordId(item);
|
||||
if (id) return `id:${id}`;
|
||||
return [
|
||||
cdrStartTime(item) || '',
|
||||
cdrCallingNumber(item) || '',
|
||||
cdrCalledNumber(item) || '',
|
||||
cdrDurationSeconds(item) || '',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function extractCdrFeedList(data) {
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data.items)) return data.items;
|
||||
if (data.items && typeof data.items === 'object' && data.items !== null) {
|
||||
const keys = Object.keys(data.items).filter((k) => /^\d+$/.test(k)).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
||||
if (keys.length > 0) return keys.map((k) => data.items[k]);
|
||||
}
|
||||
const topKeys = Object.keys(data).filter((k) => /^\d+$/.test(k)).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
||||
if (topKeys.length > 0) return topKeys.map((k) => data[k]);
|
||||
for (const key of ['records', 'cdrRecords', 'callHistory', 'calls']) {
|
||||
if (Array.isArray(data[key])) return data[key];
|
||||
}
|
||||
if (data.data) {
|
||||
if (Array.isArray(data.data)) return data.data;
|
||||
if (Array.isArray(data.data.items)) return data.data.items;
|
||||
}
|
||||
for (const v of Object.values(data)) {
|
||||
if (Array.isArray(v)) return v;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function describeCdrFeedPayload(data) {
|
||||
if (!data) return 'empty body';
|
||||
if (Array.isArray(data)) return `array[${data.length}]`;
|
||||
const keys = Object.keys(data);
|
||||
const parts = [`keys=${keys.slice(0, 12).join(',')}`];
|
||||
for (const k of ['items', 'records', 'cdrRecords', 'next', 'data']) {
|
||||
const v = data[k];
|
||||
if (Array.isArray(v)) parts.push(`${k}.length=${v.length}`);
|
||||
else if (v && typeof v === 'object') parts.push(`${k}.keys=${Object.keys(v).slice(0, 6).join(',')}`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
|
@ -13,6 +13,17 @@ import {
|
|||
import { logger } from '../utils/logger.js';
|
||||
import { normalizeMac } from './enrichment/normalizers.js';
|
||||
import { attachMerakiClientWithPorts } from './enrichment/merakiEnrichment.js';
|
||||
import {
|
||||
extractCdrFeedList,
|
||||
describeCdrFeedPayload,
|
||||
cdrCalledNumber,
|
||||
cdrCallingNumber,
|
||||
cdrDedupKey,
|
||||
cdrDirectionValue,
|
||||
cdrDispositionValue,
|
||||
cdrDurationSeconds,
|
||||
cdrStartTime,
|
||||
} from './cdrFeedParser.js';
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Main public function
|
||||
|
|
@ -594,29 +605,71 @@ export function computeRecentActivity(phones = [], dectBases = [], dectHandsets
|
|||
// The `locations` name comes from the person's DECT network (already fetched for main number / dect logic).
|
||||
// Secondary client-side filter on person numbers inside the location results.
|
||||
// (recent activity and historical calls features disabled per request)
|
||||
export async function getHistoricalCallActivity(personId, hours = 24, options = {}) {
|
||||
if (!personId) return { available: false, reason: 'no personId', calls: [], summary: {} };
|
||||
/** Last 6 chars of bearer token — enough to confirm curl vs bot use the same credential. */
|
||||
function tokenFingerprint(token) {
|
||||
if (!token || typeof token !== 'string') return 'none';
|
||||
return `…${token.slice(-6)}`;
|
||||
}
|
||||
|
||||
const { locationName: providedLocationName } = options || {};
|
||||
function getCdrAnalyticsBases() {
|
||||
const env = process.env.WEBEX_CDR_ANALYTICS_BASES;
|
||||
if (env) {
|
||||
return env.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
return [
|
||||
'https://analytics-calling.webexapis.com/v1',
|
||||
'https://analytics.webexapis.com/v1',
|
||||
];
|
||||
}
|
||||
|
||||
function formatCdrFetchError(err) {
|
||||
const msg = err?.message || String(err);
|
||||
if (msg.includes('ECONNREFUSED') && (msg.includes('0.0.0.0') || msg.includes('127.0.0.1'))) {
|
||||
return (
|
||||
`${msg} — analytics host unreachable from this container. ` +
|
||||
'Often caused by HTTP(S)_PROXY routing analytics-calling.webexapis.com through a broken proxy ' +
|
||||
'while webexapis.com is in NO_PROXY. CDR requests bypass proxy by default; add *.webexapis.com to NO_PROXY ' +
|
||||
'or set WEBEX_CDR_USE_PROXY=true if a proxy is required.'
|
||||
);
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
export async function getHistoricalCallActivity(personId, hours = 24, options = {}) {
|
||||
const {
|
||||
locationName: providedLocationName,
|
||||
startTime: optStartTime,
|
||||
endTime: optEndTime,
|
||||
returnRawItems = false,
|
||||
skipPersonFilter = false,
|
||||
} = options || {};
|
||||
|
||||
if (!personId && !providedLocationName) {
|
||||
return { available: false, reason: 'no personId or locationName', calls: [], summary: {} };
|
||||
}
|
||||
|
||||
// Prefer analytics-calling (user-confirmed working base for cdr_feed). Fall back to the other if needed.
|
||||
const ANALYTICS_BASES = [
|
||||
'https://analytics-calling.webexapis.com/v1',
|
||||
'https://analytics.webexapis.com/v1'
|
||||
];
|
||||
const ANALYTICS_BASES = getCdrAnalyticsBases();
|
||||
const CDR_PATH = '/cdr_feed';
|
||||
const MAX_PER_PAGE = 1000;
|
||||
|
||||
try {
|
||||
const person = await webex.request('GET', `people/${personId}`);
|
||||
const person = personId ? await webex.request('GET', `people/${personId}`) : null;
|
||||
const orgId = person?.orgId;
|
||||
|
||||
const token = await webex.auth.getAccessToken();
|
||||
const axiosMod = (await import('axios')).default;
|
||||
|
||||
// Get the *required* locations=name from DECT (passed in or self-discover).
|
||||
let locationName = providedLocationName;
|
||||
if (!locationName || locationName === '—') {
|
||||
if (!personId) {
|
||||
return {
|
||||
available: false,
|
||||
reason: 'no locationName (the /cdr_feed API requires the "locations" param)',
|
||||
calls: [],
|
||||
summary: {},
|
||||
};
|
||||
}
|
||||
try {
|
||||
const dectNets = await getDectNetworksForPerson(personId);
|
||||
if (dectNets.length > 0) {
|
||||
|
|
@ -636,6 +689,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
|||
summary: {},
|
||||
};
|
||||
}
|
||||
locationName = String(locationName).trim();
|
||||
|
||||
// Person identifiers for secondary filtering inside the location results.
|
||||
const userIds = new Set();
|
||||
|
|
@ -682,66 +736,65 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
|||
const FIVE_MIN_MS = 5 * 60 * 1000;
|
||||
const TWELVE_HOURS_MS = 12 * 3600 * 1000;
|
||||
|
||||
const endMs = Date.now() - FIVE_MIN_MS;
|
||||
const startMs = endMs - TWELVE_HOURS_MS;
|
||||
let endMs;
|
||||
let startMs;
|
||||
if (optStartTime && optEndTime) {
|
||||
startMs = new Date(optStartTime).getTime();
|
||||
endMs = new Date(optEndTime).getTime();
|
||||
const latestAllowedEnd = Date.now() - FIVE_MIN_MS;
|
||||
if (endMs > latestAllowedEnd) endMs = latestAllowedEnd;
|
||||
if (endMs - startMs > TWELVE_HOURS_MS) startMs = endMs - TWELVE_HOURS_MS;
|
||||
} else {
|
||||
endMs = Date.now() - FIVE_MIN_MS;
|
||||
startMs = endMs - TWELVE_HOURS_MS;
|
||||
}
|
||||
|
||||
const startTime = new Date(startMs).toISOString();
|
||||
const endTime = new Date(endMs).toISOString();
|
||||
|
||||
logger('phone:service', `Attempting Detailed Call History via cdr_feed for person ${personId} (org ${orgId || 'unknown'}) location="${locationName}" window=${startTime}..${endTime}`, 'debug');
|
||||
const tokenHint = tokenFingerprint(token);
|
||||
logger(
|
||||
'phone:service',
|
||||
`cdr_feed query location=${JSON.stringify(locationName)} window=${startTime}..${endTime} ` +
|
||||
`token=${tokenHint}`,
|
||||
'info',
|
||||
);
|
||||
|
||||
const allRawItems = [];
|
||||
const fetchErrors = [];
|
||||
|
||||
// Helper to robustly extract list, supporting:
|
||||
// - direct array
|
||||
// - .items as array
|
||||
// - .items as { "0": rec, "1": rec, ... } (items[0], items[1] style)
|
||||
// - top level numeric keys on the data object
|
||||
// - fallback to first array value found
|
||||
function extractList(data) {
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data.items)) return data.items;
|
||||
if (data.items && typeof data.items === 'object' && data.items !== null) {
|
||||
const keys = Object.keys(data.items).filter(k => /^\d+$/.test(k)).sort((a, b) => parseInt(a) - parseInt(b));
|
||||
if (keys.length > 0) return keys.map(k => data.items[k]);
|
||||
}
|
||||
// top-level numeric keyed?
|
||||
const topKeys = Object.keys(data).filter(k => /^\d+$/.test(k)).sort((a, b) => parseInt(a) - parseInt(b));
|
||||
if (topKeys.length > 0) return topKeys.map(k => data[k]);
|
||||
// any array value
|
||||
for (const v of Object.values(data)) {
|
||||
if (Array.isArray(v)) return v;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
let usedBase = null;
|
||||
let firstRespData = null;
|
||||
let lastHttpStatus = null;
|
||||
|
||||
for (const base of ANALYTICS_BASES) {
|
||||
const url = `${base}${CDR_PATH}`;
|
||||
const params = { startTime, endTime, locations: locationName, max: MAX_PER_PAGE };
|
||||
const queryStr = new URLSearchParams(params).toString();
|
||||
const fullUrl = `${url}?${queryStr}`;
|
||||
logger('phone:service', `cdr_feed request URL: ${fullUrl}`, 'debug');
|
||||
logger('phone:service', `cdr_feed GET ${fullUrl}`, 'info');
|
||||
|
||||
try {
|
||||
const resp = await axiosMod.get(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params,
|
||||
timeout: 15000
|
||||
});
|
||||
logger('phone:service', `cdr_feed response status=${resp.status} base=${base}`, 'debug');
|
||||
|
||||
const resp = await webex.analyticsRequestRaw('GET', url, { params, timeout: 15000 });
|
||||
lastHttpStatus = resp.status;
|
||||
const d = resp.data || {};
|
||||
firstRespData = d;
|
||||
|
||||
const list = extractList(d);
|
||||
const list = extractCdrFeedList(d);
|
||||
allRawItems.push(...list);
|
||||
usedBase = base;
|
||||
logger('phone:service', `cdr_feed hit on ${base} (loc=${locationName}) → ${list.length} raw (keys=${Object.keys(d).slice(0,8).join(',')})`, 'debug');
|
||||
logger(
|
||||
'phone:service',
|
||||
`cdr_feed ${base} → HTTP ${resp.status}, parsed ${list.length} record(s) (${describeCdrFeedPayload(d)})`,
|
||||
'info',
|
||||
);
|
||||
if (list.length === 0 && d && Object.keys(d).length > 0) {
|
||||
logger(
|
||||
'phone:service',
|
||||
`cdr_feed HTTP ${resp.status} but 0 parseable records — payload shape may have changed (${describeCdrFeedPayload(d)})`,
|
||||
'warn',
|
||||
);
|
||||
}
|
||||
|
||||
// Pagination support (up to 10 additional pages per rate limit)
|
||||
let nextToken = d.next || d['next'] || (d.metadata && d.metadata.next) || null;
|
||||
|
|
@ -759,14 +812,13 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
|||
const pageFull = pageParams ? `${pageUrl}?${pageQuery}` : pageUrl;
|
||||
logger('phone:service', `cdr_feed pagination page ${page} URL: ${pageFull}`, 'debug');
|
||||
try {
|
||||
const pResp = await axiosMod.get(pageUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: pageParams,
|
||||
timeout: 15000
|
||||
const pResp = await webex.analyticsRequestRaw('GET', pageUrl, {
|
||||
params: pageParams || undefined,
|
||||
timeout: 15000,
|
||||
});
|
||||
logger('phone:service', `cdr_feed pagination status=${pResp.status}`, 'debug');
|
||||
const pd = pResp.data || {};
|
||||
const pList = extractList(pd);
|
||||
const pList = extractCdrFeedList(pd);
|
||||
allRawItems.push(...pList);
|
||||
nextToken = pd.next || pd['next'] || (pd.metadata && pd.metadata.next) || null;
|
||||
} catch (pe) {
|
||||
|
|
@ -778,28 +830,57 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
|||
break; // first successful HTTP response (even if 0 items = empty window is valid)
|
||||
} catch (e) {
|
||||
const st = e.response?.status;
|
||||
const bd = e.response?.data || e.message;
|
||||
logger('phone:service', `cdr_feed ERROR for ${fullUrl} status=${st || 'n/a'}: ${JSON.stringify(bd).slice(0,300)}`, 'debug');
|
||||
fetchErrors.push({ base, startTime, endTime, status: st, body: bd });
|
||||
const errMsg = formatCdrFetchError(e);
|
||||
const bd = e.response?.data || errMsg;
|
||||
logger(
|
||||
'phone:service',
|
||||
`cdr_feed ERROR ${fullUrl} status=${st || 'n/a'}: ${typeof bd === 'string' ? bd : JSON.stringify(bd).slice(0, 300)}`,
|
||||
'warn',
|
||||
);
|
||||
fetchErrors.push({ base, startTime, endTime, status: st, body: bd, message: errMsg });
|
||||
}
|
||||
}
|
||||
|
||||
if (allRawItems.length === 0 && fetchErrors.length > 0) {
|
||||
const errSummary = fetchErrors
|
||||
.map((e) => `${e.base.replace('https://', '')}:${e.status || 'err'}`)
|
||||
.join(', ');
|
||||
const first = fetchErrors[0];
|
||||
const msg = first?.message || first?.body?.message || first?.body?.error || first?.body || 'unknown error';
|
||||
let reason = `cdr_feed failed on all bases (${errSummary}): ${typeof msg === 'string' ? msg : JSON.stringify(msg).slice(0, 200)}`;
|
||||
if (fetchErrors.some((e) => e.status === 429)) {
|
||||
reason = 'rate limited (1 cdr_feed call per minute + 10 pagination per min per token). Wait ~60s and retry.';
|
||||
}
|
||||
logger('phone:service', `cdr_feed unavailable — ${reason}`, 'warn');
|
||||
return {
|
||||
available: false,
|
||||
reason,
|
||||
fetchErrors,
|
||||
scopesNeeded: fetchErrors.some((e) => e.status === 429)
|
||||
? 'Respect API rate limits (see doc)'
|
||||
: 'spark-admin:calling_cdr_read scope + Control Hub administrator role "Webex Calling Detailed Call History API access" enabled for the authorizing user',
|
||||
calls: [],
|
||||
summary: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (allRawItems.length === 0) {
|
||||
// Legacy fallback (rarely useful now)
|
||||
try {
|
||||
const legUrl = `${ANALYTICS_BASES[0]}/callHistory`;
|
||||
const r = await axiosMod.get(legUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
const r = await webex.analyticsRequestRaw('GET', legUrl, {
|
||||
params: { orgId, personId, startTime, endTime, max: 100 },
|
||||
timeout: 15000
|
||||
timeout: 15000,
|
||||
});
|
||||
const d = r.data || {};
|
||||
const li = extractList(d);
|
||||
const li = extractCdrFeedList(d);
|
||||
if (li.length >= 0) {
|
||||
allRawItems.push(...li);
|
||||
logger('phone:service', `legacy callHistory fallback gave ${li.length}`, 'debug');
|
||||
logger('phone:service', `cdr_feed legacy callHistory fallback → ${li.length} record(s)`, 'info');
|
||||
}
|
||||
} catch (legacyErr) {
|
||||
logger('phone:service', `cdr_feed legacy callHistory fallback failed: ${legacyErr.message}`, 'debug');
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
const receivedRaw = allRawItems.length;
|
||||
|
|
@ -808,13 +889,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
|||
const seen = new Set();
|
||||
const items = [];
|
||||
for (const c of allRawItems) {
|
||||
const key = [
|
||||
c.startTime || c.start || c.callStartTime || '',
|
||||
c.callingNumber || '',
|
||||
c.calledNumber || '',
|
||||
c.duration || c.callDuration || '',
|
||||
c.callId || c.id || c.uuid || ''
|
||||
].join('|');
|
||||
const key = cdrDedupKey(c);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
items.push(c);
|
||||
|
|
@ -822,19 +897,27 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
|||
}
|
||||
|
||||
// Secondary person filter inside the location-scoped results.
|
||||
const userItems = items.filter(matchItemToUser);
|
||||
const userItems = skipPersonFilter ? items : items.filter(matchItemToUser);
|
||||
const effectiveItems = (userItems.length > 0) ? userItems : items;
|
||||
if (userItems.length === 0 && items.length > 0) {
|
||||
if (userItems.length === 0 && items.length > 0 && !skipPersonFilter) {
|
||||
logger('phone:service', `cdr_feed: ${items.length} unique (received ${receivedRaw}, loc=${locationName}) but 0 matched person ids; using location results (common for main/AA numbers)`, 'debug');
|
||||
}
|
||||
|
||||
logger(
|
||||
'phone:service',
|
||||
`cdr_feed done base=${usedBase || 'none'} http=${lastHttpStatus ?? 'n/a'} ` +
|
||||
`raw=${receivedRaw} unique=${items.length} returned=${effectiveItems.length} ` +
|
||||
`personFilter=${skipPersonFilter ? 'off' : 'on'} token=${tokenHint}`,
|
||||
'info',
|
||||
);
|
||||
|
||||
// Flexible parse (CDR field names vary; covers common report columns)
|
||||
let inbound = 0, outbound = 0, missed = 0, totalDuration = 0;
|
||||
const samples = [];
|
||||
for (const c of effectiveItems) {
|
||||
const dir = String(c.direction || c.callDirection || c.callType || c.directionIndicator || '').toUpperCase();
|
||||
const dur = Number(c.durationSeconds || c.duration || c.callDuration || c.talkDuration || c.length || 0);
|
||||
const status = String(c.status || c.result || c.callResult || c.callOutcome || c.callStatus || '').toLowerCase();
|
||||
const dir = cdrDirectionValue(c);
|
||||
const dur = cdrDurationSeconds(c);
|
||||
const status = cdrDispositionValue(c).toLowerCase();
|
||||
|
||||
if (dir.includes('IN') || dir === 'INBOUND' || dir.includes('INCOMING')) inbound++;
|
||||
else if (dir.includes('OUT') || dir === 'OUTBOUND' || dir.includes('OUTGOING')) outbound++;
|
||||
|
|
@ -845,12 +928,13 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
|||
|
||||
if (samples.length < 5) {
|
||||
samples.push({
|
||||
start: c.startTime || c.start || c.callStartTime || c.answerTime || c.releaseTime,
|
||||
start: cdrStartTime(c) || c.releaseTime,
|
||||
direction: dir || c.direction,
|
||||
duration: dur,
|
||||
status: c.status || c.result || c.callResult,
|
||||
otherParty: c.otherParty || c.calledParty || c.callingParty || c.remoteParty || c.calledNumber || c.callingNumber || 'unknown',
|
||||
phoneNumber: c.phoneNumber || c.calledNumber || c.callingNumber,
|
||||
status: status || cdrDispositionValue(c),
|
||||
otherParty: c.otherParty || c.calledParty || c.callingParty || c.remoteParty
|
||||
|| cdrCalledNumber(c) || cdrCallingNumber(c) || 'unknown',
|
||||
phoneNumber: c.phoneNumber || cdrCalledNumber(c) || cdrCallingNumber(c),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -872,6 +956,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
|||
samples,
|
||||
rawCount: receivedRaw,
|
||||
userMatchedCount: userItems.length,
|
||||
rawItems: returnRawItems ? effectiveItems : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
|
|
|
|||
|
|
@ -85,6 +85,128 @@ export function renderCallTestResultMarkdown(session) {
|
|||
return md.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} session
|
||||
* @param {object} details from fetchCallResource
|
||||
*/
|
||||
export function renderCallTestTwilioDetailsMarkdown(session, details) {
|
||||
let md = `**Call test — Twilio details** (\`${session.testId}\`)\n\n`;
|
||||
if (!details?.available) {
|
||||
md += `_Unavailable:_ ${details?.reason || 'unknown'}\n`;
|
||||
if (session.callSid) md += `- **CallSid:** \`${session.callSid}\`\n`;
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
const d = details.data || {};
|
||||
md += `- **CallSid:** \`${d.sid || session.callSid}\`\n`;
|
||||
md += `- **Status:** ${d.status || '—'}\n`;
|
||||
md += `- **Duration:** ${d.duration != null ? `${d.duration}s` : '—'}\n`;
|
||||
if (d.startTime) md += `- **Start:** ${fmtTime(d.startTime)}\n`;
|
||||
if (d.endTime) md += `- **End:** ${fmtTime(d.endTime)}\n`;
|
||||
md += `- **From:** ${d.from || '—'}\n`;
|
||||
md += `- **To:** ${d.to || '—'}\n`;
|
||||
if (d.price != null) md += `- **Price:** ${d.price} ${d.priceUnit || ''}\n`.trim() + '\n';
|
||||
if (d.answeredBy) md += `- **Answered by:** ${d.answeredBy}\n`;
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} session
|
||||
* @param {object} insights combined summary + metrics
|
||||
*/
|
||||
export function renderCallTestTwilioInsightsMarkdown(session, insights) {
|
||||
let md = `**Call test — Twilio Insights** (\`${session.testId}\`)\n\n`;
|
||||
if (!insights?.available) {
|
||||
md += `_Unavailable:_ ${insights?.reason || 'Voice Insights not available'}\n`;
|
||||
md += '\n_Enable Voice Insights Advanced Features in Twilio Console for MOS/jitter/loss metrics._\n';
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
const s = insights.summary;
|
||||
if (s) {
|
||||
md += '**Summary**\n';
|
||||
if (s.duration != null) md += `- Duration: ${s.duration}s\n`;
|
||||
if (s.connectDuration != null) md += `- Connect duration: ${s.connectDuration}s\n`;
|
||||
if (s.processingState) md += `- Processing: ${s.processingState}\n`;
|
||||
}
|
||||
|
||||
const m = insights.metrics;
|
||||
if (m?.highlights) {
|
||||
md += '\n**Quality (carrier edge)**\n';
|
||||
const h = m.highlights;
|
||||
if (h.maxJitter != null) md += `- Max jitter: ${h.maxJitter}\n`;
|
||||
if (h.maxPacketLoss != null) md += `- Max packet loss: ${h.maxPacketLoss}%\n`;
|
||||
if (h.minMos != null) md += `- Min MOS: ${h.minMos}\n`;
|
||||
}
|
||||
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} session
|
||||
* @param {object} cdrResult from fetchMatchedCdrForSession
|
||||
*/
|
||||
export function renderCallTestCdrMatchMarkdown(session, cdrResult) {
|
||||
let md = `**Call test — Webex CDR match** (\`${session.testId}\`)\n\n`;
|
||||
|
||||
if (!cdrResult?.available) {
|
||||
md += `_CDR unavailable:_ ${cdrResult?.reason || 'unknown'}\n`;
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
if (cdrResult.window) {
|
||||
md += `- **Window:** ${cdrResult.window.startTime} → ${cdrResult.window.endTime}\n`;
|
||||
}
|
||||
if (cdrResult.location) md += `- **Location:** ${cdrResult.location}\n`;
|
||||
md += `- **Records in window:** ${cdrResult.rawCount ?? '—'}\n`;
|
||||
|
||||
const match = cdrResult.match;
|
||||
if (!match) {
|
||||
md += `\n_No matching leg found._ ${cdrResult.reason || ''}\n`;
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
md += '\n**Matched leg**\n';
|
||||
md += `- **Start:** ${fmtTime(match.start)}\n`;
|
||||
md += `- **Direction:** ${match.direction || '—'}\n`;
|
||||
md += `- **Duration:** ${match.duration ?? '—'}s\n`;
|
||||
md += `- **Status:** ${match.status || '—'}\n`;
|
||||
if (match.callingNumber) md += `- **Calling:** ${match.callingNumber}\n`;
|
||||
if (match.calledNumber) md += `- **Called:** ${match.calledNumber}\n`;
|
||||
if (match.score != null) md += `- **Match score:** ${match.score}\n`;
|
||||
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append enrichment status for /calltest status.
|
||||
*/
|
||||
export function renderCallTestEnrichmentStatusMarkdown(session) {
|
||||
const e = session.enrichment || {};
|
||||
const lines = ['\n**Enrichment:**'];
|
||||
|
||||
const tw = e.twilio || {};
|
||||
if (tw.detailsFetchedAt) {
|
||||
lines.push(`- Twilio details: ${tw.details?.available ? 'ready' : 'failed'}`);
|
||||
} else {
|
||||
lines.push('- Twilio details: pending');
|
||||
}
|
||||
if (tw.insightsFetchedAt) {
|
||||
lines.push(`- Twilio Insights: ${tw.insights?.available ? 'ready' : 'unavailable'}`);
|
||||
} else {
|
||||
lines.push('- Twilio Insights: pending');
|
||||
}
|
||||
|
||||
const cdr = e.cdr;
|
||||
if (cdr?.fetchedAt) {
|
||||
lines.push(`- Webex CDR: ${cdr.match ? 'matched' : 'no match'}`);
|
||||
} else {
|
||||
lines.push('- Webex CDR: pending');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} session
|
||||
* @param {object} cdr
|
||||
|
|
|
|||
161
services/renderers/voiceReportRenderer.js
Normal file
161
services/renderers/voiceReportRenderer.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// services/renderers/voiceReportRenderer.js
|
||||
|
||||
function envInt(name, fallback) {
|
||||
const v = Number(process.env[name]);
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
}
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
|
||||
} catch {
|
||||
return String(iso);
|
||||
}
|
||||
}
|
||||
|
||||
function countPoorQuality(calls) {
|
||||
const mosWarn = Number(process.env.WAN_STANDARD_APP_MOS_WARN || 4.0);
|
||||
const lossWarn = Number(process.env.WAN_STANDARD_APP_LOSS_WARN_PCT || 5);
|
||||
const jitterWarn = Number(process.env.WAN_STANDARD_APP_JITTER_WARN_MS || 30);
|
||||
let poor = 0;
|
||||
for (const call of calls) {
|
||||
const q = call.media || call.wan || {};
|
||||
if (q.mos != null && q.mos < mosWarn) poor++;
|
||||
else if (q.loss != null && q.loss > lossWarn) poor++;
|
||||
else if (q.jitter != null && q.jitter > jitterWarn) poor++;
|
||||
}
|
||||
return poor;
|
||||
}
|
||||
|
||||
function renderOutcomeBreakdown(outcomes) {
|
||||
const entries = Object.entries(outcomes || {});
|
||||
if (!entries.length) return '_No outcome data._';
|
||||
return entries
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `- **${k}:** ${v}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function renderReachSummary(summary) {
|
||||
const lines = [];
|
||||
if (summary.inbound > 0) {
|
||||
lines.push(
|
||||
`- **Inbound reached a phone:** ${summary.inboundReachedPhone} of ${summary.inbound}`,
|
||||
);
|
||||
if (summary.inboundAaOnly > 0) {
|
||||
lines.push(`- **Inbound stopped at auto-attendant:** ${summary.inboundAaOnly}`);
|
||||
}
|
||||
}
|
||||
if (summary.outbound > 0) {
|
||||
lines.push(
|
||||
`- **Outbound connected:** ${summary.outboundConnected} of ${summary.outbound}`,
|
||||
);
|
||||
}
|
||||
if (summary.abnormal > 0) {
|
||||
lines.push(`- **Abnormal outcomes:** ${summary.abnormal}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderCallReachLine(call) {
|
||||
if (call.direction === 'inbound') {
|
||||
if (call.reachedPhone) {
|
||||
const who = [call.endpointUser, call.endpointModel].filter(Boolean).join(' / ');
|
||||
return `reached ${who || 'phone'}`;
|
||||
}
|
||||
if (call.reachedAttendant) {
|
||||
const key = call.aaKeyPress ? ` (AA key ${call.aaKeyPress})` : '';
|
||||
return `auto-attendant only${key}`;
|
||||
}
|
||||
return 'not answered';
|
||||
}
|
||||
if (call.direction === 'outbound') {
|
||||
return call.connected || call.reachedPhone ? 'connected' : 'not connected';
|
||||
}
|
||||
return call.outcome || '—';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} report from collectVoiceReport
|
||||
* @param {{ detail?: boolean }} opts
|
||||
*/
|
||||
export function renderVoiceReportMarkdown(report, opts = {}) {
|
||||
if (!report?.ok) {
|
||||
let md = `**Voice report** — Store ${report?.store?.storeNum || '?'}\n\n`;
|
||||
md += `_${report?.reason || report?.window?.reason || 'Unable to build report.'}_\n`;
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
const { store, window, cdr, mediaQuality, prisma, joined } = report;
|
||||
const calls = joined.calls || joined.legs || [];
|
||||
const summary = joined.summary || {};
|
||||
const maxRows = envInt('VOICEREPORT_MAX_DETAIL_ROWS', 25);
|
||||
const detail = Boolean(opts.detail);
|
||||
|
||||
let md = `**Voice report — Store ${store.storeNum}**\n\n`;
|
||||
md += `- **Date:** ${window.label} (${window.mode})\n`;
|
||||
md += `- **Timezone:** ${window.timeZone}\n`;
|
||||
md += `- **Window:** ${fmtTime(window.startTime)} → ${fmtTime(window.endTime)}\n`;
|
||||
md += `- **Location:** ${store.locationName}\n`;
|
||||
if (store.dialNumber) md += `- **Main:** ${store.dialNumber}\n`;
|
||||
|
||||
md += '\n**Call volume (CDR)**\n';
|
||||
if (!cdr.available) {
|
||||
md += `_CDR unavailable:_ ${cdr.reason || 'unknown'}\n`;
|
||||
} else {
|
||||
md += `- **Total calls:** ${summary.total ?? calls.length}\n`;
|
||||
md += `- **Inbound:** ${summary.inbound ?? 0} | **Outbound:** ${summary.outbound ?? 0}\n`;
|
||||
md += `- **CDR legs fetched:** ${cdr.rawCount ?? '—'} → **${summary.total ?? calls.length}** correlated call(s)\n`;
|
||||
const reach = renderReachSummary(summary);
|
||||
if (reach) md += `${reach}\n`;
|
||||
md += '\n**Call outcomes**\n';
|
||||
md += `${renderOutcomeBreakdown(summary.outcomes)}\n`;
|
||||
}
|
||||
|
||||
md += '\n**Media quality (Webex report)**\n';
|
||||
if (!mediaQuality.available) {
|
||||
md += `_Unavailable:_ ${mediaQuality.reason || 'not generated'}\n`;
|
||||
} else {
|
||||
md += `- **Rows in window:** ${mediaQuality.rows?.length ?? 0} (from ${mediaQuality.rawCount ?? '?'} downloaded)\n`;
|
||||
const poor = countPoorQuality(calls.filter((c) => c.media));
|
||||
md += `- **Calls with quality flags:** ${poor}\n`;
|
||||
}
|
||||
|
||||
md += '\n**WAN voice (Prisma DPI)**\n';
|
||||
if (!prisma.available) {
|
||||
md += `_Unavailable:_ ${prisma.reason || 'not configured'}\n`;
|
||||
} else {
|
||||
const m = prisma.appAudio?.mos;
|
||||
md += `- **App:** ${prisma.appAudio.appName}\n`;
|
||||
if (m?.min != null) md += `- **Worst MOS in window:** ${m.min}\n`;
|
||||
if (m?.max != null) md += `- **Best MOS in window:** ${m.max}\n`;
|
||||
if (prisma.appAudio?.detailsUrl) {
|
||||
md += `- [View in Prisma SCM](${prisma.appAudio.detailsUrl})\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (detail && calls.length) {
|
||||
md += '\n**Call detail**\n';
|
||||
const show = calls.slice(0, maxRows);
|
||||
for (const call of show) {
|
||||
const dir = String(call.direction || 'unknown').toUpperCase();
|
||||
const outcome = [call.outcome, call.outcomeReason].filter(Boolean).join(' / ');
|
||||
const flag = call.abnormal || !call.normalOutcome ? ' ⚠️' : '';
|
||||
md += `\n- **${fmtTime(call.start)}** ${dir} ${call.duration || 0}s — ${outcome || '—'}${flag}\n`;
|
||||
md += ` ${call.callingNumber || '?'} → ${call.calledNumber || '?'} (${call.legCount} leg(s), ${renderCallReachLine(call)})\n`;
|
||||
if (call.media) {
|
||||
md += ` Media: MOS ${call.media.mos ?? '—'}, jitter ${call.media.jitter ?? '—'}, loss ${call.media.loss ?? '—'}\n`;
|
||||
}
|
||||
if (call.wan) {
|
||||
md += ` WAN≈: MOS ${call.wan.mos ?? '—'}, jitter ${call.wan.jitter ?? '—'}, loss ${call.wan.loss ?? '—'}\n`;
|
||||
}
|
||||
}
|
||||
if (calls.length > maxRows) {
|
||||
md += `\n_${calls.length - maxRows} more calls — use Control Hub Detailed Call History for full export._\n`;
|
||||
}
|
||||
}
|
||||
|
||||
md += `\n*Generated in ${report.elapsedMs}ms*`;
|
||||
return md.trim();
|
||||
}
|
||||
144
services/voiceReport/businessWindow.js
Normal file
144
services/voiceReport/businessWindow.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// services/voiceReport/businessWindow.js
|
||||
// Store-local business-hour windows for /voicereport (default 9am–9pm).
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { DISPLAY_TIMEZONE } from '../../utils/time.js';
|
||||
|
||||
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
function envInt(name, fallback) {
|
||||
const v = Number(process.env[name]);
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
}
|
||||
|
||||
export function getBusinessHourConfig() {
|
||||
return {
|
||||
startHour: envInt('VOICEREPORT_BUSINESS_START_HOUR', 9),
|
||||
endHour: envInt('VOICEREPORT_BUSINESS_END_HOUR', 21),
|
||||
apiLagMs: envInt('VOICEREPORT_API_LAG_MS', 5 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse user date arg into a mode + calendar date in store TZ.
|
||||
* @param {string|null|undefined} raw
|
||||
* @param {string} timeZone IANA zone
|
||||
* @param {Date} [now] injectable for tests
|
||||
*/
|
||||
export function parseReportDateArg(raw, timeZone, now = new Date()) {
|
||||
const zone = timeZone || DISPLAY_TIMEZONE;
|
||||
const nowLocal = DateTime.fromJSDate(now, { zone });
|
||||
const token = String(raw || 'yesterday').trim().toLowerCase();
|
||||
|
||||
if (!token || token === 'yesterday') {
|
||||
const day = nowLocal.minus({ days: 1 }).startOf('day');
|
||||
return { mode: 'fullDay', day, label: day.toISODate() };
|
||||
}
|
||||
if (token === 'today') {
|
||||
return { mode: 'today', day: nowLocal.startOf('day'), label: nowLocal.toISODate() };
|
||||
}
|
||||
const parsed = DateTime.fromISO(token, { zone });
|
||||
if (parsed.isValid) {
|
||||
return { mode: 'fullDay', day: parsed.startOf('day'), label: parsed.toISODate() };
|
||||
}
|
||||
return { error: `Unrecognised date "${raw}". Use yesterday, today, or YYYY-MM-DD.` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute UTC ISO window for cdr_feed / Prisma queries.
|
||||
* @returns {{ startTime: string, endTime: string, timeZone: string, label: string, mode: string, ready: boolean, reason?: string }}
|
||||
*/
|
||||
export function computeBusinessWindow({ timeZone, dateArg, now = new Date() }) {
|
||||
const zone = timeZone || DISPLAY_TIMEZONE;
|
||||
const cfg = getBusinessHourConfig();
|
||||
const parsed = parseReportDateArg(dateArg, zone, now);
|
||||
if (parsed.error) {
|
||||
return { ready: false, reason: parsed.error, timeZone: zone };
|
||||
}
|
||||
|
||||
const startLocal = parsed.day.set({
|
||||
hour: cfg.startHour,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
});
|
||||
|
||||
let endLocal;
|
||||
const nowLocal = DateTime.fromJSDate(now, { zone });
|
||||
|
||||
if (parsed.mode === 'today') {
|
||||
const businessEnd = parsed.day.set({
|
||||
hour: cfg.endHour,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
});
|
||||
const lagEnd = nowLocal.minus({ milliseconds: cfg.apiLagMs });
|
||||
if (lagEnd < startLocal.plus({ minutes: 1 })) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: 'Business window has not started yet, or insufficient data after the 5-minute API lag.',
|
||||
timeZone: zone,
|
||||
label: parsed.label,
|
||||
mode: parsed.mode,
|
||||
};
|
||||
}
|
||||
endLocal = DateTime.min(lagEnd, businessEnd);
|
||||
} else {
|
||||
endLocal = parsed.day.set({
|
||||
hour: cfg.endHour,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (endLocal <= startLocal) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: 'Computed window is empty (end is not after start).',
|
||||
timeZone: zone,
|
||||
label: parsed.label,
|
||||
mode: parsed.mode,
|
||||
};
|
||||
}
|
||||
|
||||
let startMs = startLocal.toUTC().toMillis();
|
||||
let endMs = endLocal.toUTC().toMillis();
|
||||
|
||||
const latestAllowedEnd = now.getTime() - cfg.apiLagMs;
|
||||
if (endMs > latestAllowedEnd) endMs = latestAllowedEnd;
|
||||
if (endMs - startMs > TWELVE_HOURS_MS) startMs = endMs - TWELVE_HOURS_MS;
|
||||
|
||||
if (startMs >= endMs) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: 'Window invalid after API lag clamp.',
|
||||
timeZone: zone,
|
||||
label: parsed.label,
|
||||
mode: parsed.mode,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
timeZone: zone,
|
||||
label: parsed.label,
|
||||
mode: parsed.mode,
|
||||
startTime: new Date(startMs).toISOString(),
|
||||
endTime: new Date(endMs).toISOString(),
|
||||
startLocal: startLocal.toISO(),
|
||||
endLocal: endLocal.toISO(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a timestamp (ISO or ms) falls inside the business window.
|
||||
*/
|
||||
export function isWithinWindow(isoOrMs, window) {
|
||||
if (!window?.startTime || !window?.endTime) return false;
|
||||
const t = new Date(isoOrMs).getTime();
|
||||
const s = new Date(window.startTime).getTime();
|
||||
const e = new Date(window.endTime).getTime();
|
||||
return t >= s && t <= e;
|
||||
}
|
||||
250
services/voiceReport/groupCdrCalls.js
Normal file
250
services/voiceReport/groupCdrCalls.js
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
// services/voiceReport/groupCdrCalls.js
|
||||
// Group cdr_feed legs by Correlation ID into logical calls.
|
||||
|
||||
import {
|
||||
cdrCalledNumber,
|
||||
cdrCallingNumber,
|
||||
cdrDirectionValue,
|
||||
cdrDurationSeconds,
|
||||
cdrField,
|
||||
cdrRecordId,
|
||||
cdrStartTime,
|
||||
cdrDedupKey,
|
||||
} from '../cdrFeedParser.js';
|
||||
|
||||
export function cdrCorrelationId(item) {
|
||||
return cdrField(item, 'correlationId', 'Correlation ID', 'correlationID');
|
||||
}
|
||||
|
||||
function isTruthyAnswered(item) {
|
||||
const answered = String(cdrField(item, 'Answered', 'answered') || '').toLowerCase();
|
||||
const indicator = String(cdrField(item, 'Answer indicator', 'answer indicator') || '');
|
||||
return answered === 'true' || indicator === 'Yes';
|
||||
}
|
||||
|
||||
function isUserPhoneLeg(item) {
|
||||
const userType = String(cdrField(item, 'User type', 'userType') || '');
|
||||
const clientType = String(cdrField(item, 'Client type', 'clientType') || '').toUpperCase();
|
||||
const model = String(cdrField(item, 'Model', 'model') || '');
|
||||
if (userType === 'User') return true;
|
||||
if (clientType === 'WXC_DEVICE') return true;
|
||||
if (model.toUpperCase().includes('DBS')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAutomatedAttendantLeg(item) {
|
||||
const userType = String(cdrField(item, 'User type', 'userType') || '').toLowerCase();
|
||||
return userType.includes('automatedattendant');
|
||||
}
|
||||
|
||||
function callTypeValue(item) {
|
||||
return String(cdrField(item, 'Call type', 'callType') || '').toUpperCase();
|
||||
}
|
||||
|
||||
export function classifyCallDirection(legs) {
|
||||
for (const leg of legs) {
|
||||
const ct = callTypeValue(leg);
|
||||
if (ct.includes('INBOUND')) return 'inbound';
|
||||
if (ct.includes('OUTBOUND')) return 'outbound';
|
||||
}
|
||||
for (const leg of legs) {
|
||||
const dir = cdrDirectionValue(leg);
|
||||
const inboundTrunk = cdrField(leg, 'Inbound trunk', 'inbound trunk');
|
||||
const outboundTrunk = cdrField(leg, 'Outbound trunk', 'outbound trunk');
|
||||
if (inboundTrunk && dir.includes('TERMINAT')) return 'inbound';
|
||||
if (outboundTrunk && dir.includes('ORIGINAT')) return 'outbound';
|
||||
}
|
||||
for (const leg of legs) {
|
||||
const dir = cdrDirectionValue(leg);
|
||||
if (dir.includes('ORIGINAT')) return 'outbound';
|
||||
if (dir.includes('TERMINAT')) return 'inbound';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function isNormalCallOutcome(item) {
|
||||
const outcome = String(cdrField(item, 'Call outcome', 'call outcome') || '').toLowerCase();
|
||||
const reason = String(cdrField(item, 'Call outcome reason', 'call outcome reason') || '').toLowerCase();
|
||||
if (!outcome) return true;
|
||||
if (outcome !== 'success') return false;
|
||||
return !reason || reason === 'normal';
|
||||
}
|
||||
|
||||
function pickExternalNumber(legs, direction) {
|
||||
for (const leg of legs) {
|
||||
const ct = callTypeValue(leg);
|
||||
if (direction === 'inbound' && ct.includes('INBOUND')) {
|
||||
return cdrCallingNumber(leg) || cdrField(leg, 'Caller ID number', 'caller id number');
|
||||
}
|
||||
if (direction === 'outbound' && ct.includes('OUTBOUND')) {
|
||||
return cdrCalledNumber(leg);
|
||||
}
|
||||
}
|
||||
const first = legs[0];
|
||||
if (!first) return null;
|
||||
if (direction === 'inbound') {
|
||||
return cdrCallingNumber(first) || cdrField(first, 'Caller ID number', 'caller id number');
|
||||
}
|
||||
return cdrCalledNumber(first);
|
||||
}
|
||||
|
||||
function pickTerminalOutcome(legs) {
|
||||
// Prefer the user-phone leg, then any leg with explicit outcome.
|
||||
const phoneLegs = legs.filter(isUserPhoneLeg);
|
||||
const candidates = phoneLegs.length ? phoneLegs : legs;
|
||||
let chosen = candidates[candidates.length - 1];
|
||||
for (const leg of candidates) {
|
||||
if (cdrField(leg, 'Call outcome', 'call outcome')) {
|
||||
chosen = leg;
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: cdrField(chosen, 'Call outcome', 'call outcome') || 'unknown',
|
||||
reason: cdrField(chosen, 'Call outcome reason', 'call outcome reason') || '',
|
||||
normal: isNormalCallOutcome(chosen),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeReach(legs, direction) {
|
||||
const phoneLegs = legs.filter(isUserPhoneLeg);
|
||||
const answeredPhone = phoneLegs.find(isTruthyAnswered);
|
||||
const aaLegs = legs.filter(isAutomatedAttendantLeg);
|
||||
const answeredAa = aaLegs.find(isTruthyAnswered);
|
||||
|
||||
if (direction === 'inbound') {
|
||||
return {
|
||||
reachedPhone: Boolean(answeredPhone),
|
||||
reachedAttendant: Boolean(answeredAa),
|
||||
endpointUser: answeredPhone
|
||||
? cdrField(answeredPhone, 'User', 'user')
|
||||
: null,
|
||||
endpointModel: answeredPhone
|
||||
? cdrField(answeredPhone, 'Model', 'model')
|
||||
: null,
|
||||
aaKeyPress: aaLegs.map((l) => cdrField(l, 'Auto Attendant Key Pressed', 'auto attendant key pressed'))
|
||||
.find((v) => v && v !== 'NA') || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Outbound: originating user/device leg answered, remote side picked up on terminating leg.
|
||||
const originating = legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT') && isUserPhoneLeg(l));
|
||||
const remoteAnswered = legs.some((l) =>
|
||||
cdrDirectionValue(l).includes('TERMINAT') && isTruthyAnswered(l) && !isAutomatedAttendantLeg(l),
|
||||
);
|
||||
return {
|
||||
reachedPhone: Boolean(originating && isTruthyAnswered(originating)),
|
||||
connected: remoteAnswered || (originating && isTruthyAnswered(originating)),
|
||||
endpointUser: originating ? cdrField(originating, 'User', 'user') : null,
|
||||
endpointModel: originating ? cdrField(originating, 'Model', 'model') : null,
|
||||
reachedAttendant: false,
|
||||
aaKeyPress: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object[]} rawLegs
|
||||
* @returns {object}
|
||||
*/
|
||||
export function summarizeCallGroup(correlationId, rawLegs) {
|
||||
const legs = [...(rawLegs || [])].sort((a, b) => {
|
||||
const ta = new Date(cdrStartTime(a) || 0).getTime();
|
||||
const tb = new Date(cdrStartTime(b) || 0).getTime();
|
||||
return ta - tb;
|
||||
});
|
||||
const direction = classifyCallDirection(legs);
|
||||
const starts = legs.map((l) => cdrStartTime(l)).filter(Boolean);
|
||||
const start = starts.length ? starts.sort()[0] : null;
|
||||
const releaseTimes = legs
|
||||
.map((l) => cdrField(l, 'Release time', 'release time'))
|
||||
.filter(Boolean);
|
||||
const end = releaseTimes.length ? releaseTimes.sort().reverse()[0] : null;
|
||||
const duration = (() => {
|
||||
if (start && end) {
|
||||
return Math.max(0, Math.round((new Date(end).getTime() - new Date(start).getTime()) / 1000));
|
||||
}
|
||||
return Math.max(...legs.map(cdrDurationSeconds), 0);
|
||||
})();
|
||||
|
||||
const outcome = pickTerminalOutcome(legs);
|
||||
const reach = summarizeReach(legs, direction);
|
||||
const abnormal = legs.some((l) => !isNormalCallOutcome(l));
|
||||
|
||||
return {
|
||||
correlationId,
|
||||
direction,
|
||||
start,
|
||||
end,
|
||||
duration,
|
||||
callingNumber: pickExternalNumber(legs, direction) || cdrCallingNumber(legs[0]),
|
||||
calledNumber: cdrCalledNumber(legs[legs.length - 1]) || cdrCalledNumber(legs[0]),
|
||||
outcome: outcome.outcome,
|
||||
outcomeReason: outcome.reason,
|
||||
normalOutcome: outcome.normal && !abnormal,
|
||||
abnormal,
|
||||
legCount: legs.length,
|
||||
...reach,
|
||||
legs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object[]} rawItems deduped cdr_feed rows
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function groupCdrIntoCalls(rawItems) {
|
||||
const groups = new Map();
|
||||
for (const item of rawItems || []) {
|
||||
const corr = cdrCorrelationId(item);
|
||||
const key = corr
|
||||
? `corr:${corr}`
|
||||
: `solo:${cdrRecordId(item) || cdrDedupKey(item)}`;
|
||||
if (!groups.has(key)) groups.set(key, { correlationId: corr, legs: [] });
|
||||
groups.get(key).legs.push(item);
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
.map((g) => summarizeCallGroup(g.correlationId, g.legs))
|
||||
.sort((a, b) => new Date(a.start || 0).getTime() - new Date(b.start || 0).getTime());
|
||||
}
|
||||
|
||||
export function summarizeCalls(calls) {
|
||||
let inbound = 0;
|
||||
let outbound = 0;
|
||||
let unknown = 0;
|
||||
let inboundReachedPhone = 0;
|
||||
let inboundAaOnly = 0;
|
||||
let outboundConnected = 0;
|
||||
let abnormal = 0;
|
||||
|
||||
for (const call of calls) {
|
||||
if (call.direction === 'inbound') {
|
||||
inbound++;
|
||||
if (call.reachedPhone) inboundReachedPhone++;
|
||||
else if (call.reachedAttendant) inboundAaOnly++;
|
||||
} else if (call.direction === 'outbound') {
|
||||
outbound++;
|
||||
if (call.connected || call.reachedPhone) outboundConnected++;
|
||||
} else {
|
||||
unknown++;
|
||||
}
|
||||
if (call.abnormal || !call.normalOutcome) abnormal++;
|
||||
}
|
||||
|
||||
const outcomes = {};
|
||||
for (const call of calls) {
|
||||
const key = [call.outcome, call.outcomeReason].filter(Boolean).join(' / ') || 'unknown';
|
||||
outcomes[key] = (outcomes[key] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
total: calls.length,
|
||||
inbound,
|
||||
outbound,
|
||||
unknown,
|
||||
inboundReachedPhone,
|
||||
inboundAaOnly,
|
||||
outboundConnected,
|
||||
abnormal,
|
||||
outcomes,
|
||||
};
|
||||
}
|
||||
199
services/voiceReport/joinCallQuality.js
Normal file
199
services/voiceReport/joinCallQuality.js
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// services/voiceReport/joinCallQuality.js
|
||||
// Join CDR legs, Media Quality rows, and Prisma WAN buckets.
|
||||
|
||||
import { isWithinWindow } from './businessWindow.js';
|
||||
import {
|
||||
cdrCalledNumber,
|
||||
cdrCallingNumber,
|
||||
cdrDirectionValue,
|
||||
cdrDispositionValue,
|
||||
cdrDurationSeconds,
|
||||
cdrField,
|
||||
cdrStartTime,
|
||||
} from '../cdrFeedParser.js';
|
||||
import { groupCdrIntoCalls, summarizeCalls } from './groupCdrCalls.js';
|
||||
|
||||
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 cdrDisposition(item) {
|
||||
return cdrDispositionValue(item);
|
||||
}
|
||||
|
||||
function cdrDirection(item) {
|
||||
return cdrDirectionValue(item);
|
||||
}
|
||||
|
||||
function cdrStart(item) {
|
||||
return cdrStartTime(item);
|
||||
}
|
||||
|
||||
function cdrDuration(item) {
|
||||
return cdrDurationSeconds(item);
|
||||
}
|
||||
|
||||
function normalizeCdrLeg(item) {
|
||||
return {
|
||||
start: cdrStart(item),
|
||||
direction: cdrDirection(item),
|
||||
duration: cdrDuration(item),
|
||||
disposition: cdrDisposition(item),
|
||||
callingNumber: cdrCallingNumber(item),
|
||||
calledNumber: cdrCalledNumber(item),
|
||||
correlationId: cdrField(item, 'correlationId', 'Correlation ID', 'correlationID'),
|
||||
raw: item,
|
||||
};
|
||||
}
|
||||
|
||||
function mqCorrelation(row) {
|
||||
const n = row._norm || {};
|
||||
return n.correlationid || n.finalcorrelationid || row['Correlation ID'] || null;
|
||||
}
|
||||
|
||||
function mqStart(row) {
|
||||
const n = row._norm || {};
|
||||
return n.starttime || n.callstarttime || row['Start Time'] || null;
|
||||
}
|
||||
|
||||
function mqNumbers(row) {
|
||||
const n = row._norm || {};
|
||||
return [
|
||||
n.callingnumber, n.callednumber, n.remoteparty,
|
||||
row['Calling Number'], row['Called Number'],
|
||||
].filter(Boolean).map(phoneDigits);
|
||||
}
|
||||
|
||||
function mqQuality(row) {
|
||||
const n = row._norm || {};
|
||||
const mos = Number(n.mos || n.meanopinionscore || n.audiomos || NaN);
|
||||
const jitter = Number(n.jitter || n.jitterms || NaN);
|
||||
const loss = Number(n.packetloss || n.packetlosspct || n.loss || NaN);
|
||||
return {
|
||||
mos: Number.isFinite(mos) ? mos : null,
|
||||
jitter: Number.isFinite(jitter) ? jitter : null,
|
||||
loss: Number.isFinite(loss) ? loss : null,
|
||||
};
|
||||
}
|
||||
|
||||
function scoreMqMatch(call, row) {
|
||||
const callCorr = call.correlationId ? String(call.correlationId).toLowerCase() : '';
|
||||
const rowCorr = mqCorrelation(row);
|
||||
if (callCorr && rowCorr && String(rowCorr).toLowerCase() === callCorr) return 100;
|
||||
|
||||
const legStart = call.start ? new Date(call.start).getTime() : NaN;
|
||||
const rowStart = mqStart(row) ? new Date(mqStart(row)).getTime() : NaN;
|
||||
if (!Number.isFinite(legStart) || !Number.isFinite(rowStart)) return 0;
|
||||
|
||||
const delta = Math.abs(legStart - rowStart);
|
||||
if (delta > 120_000) return 0;
|
||||
|
||||
let score = 50;
|
||||
if (delta < 15_000) score += 30;
|
||||
else if (delta < 60_000) score += 10;
|
||||
|
||||
const legNums = new Set([
|
||||
phoneDigits(call.callingNumber),
|
||||
phoneDigits(call.calledNumber),
|
||||
].filter(Boolean));
|
||||
const rowNums = mqNumbers(row);
|
||||
if (rowNums.some((n) => legNums.has(n))) score += 20;
|
||||
return score;
|
||||
}
|
||||
|
||||
export function matchMediaQualityToCall(call, mqRows) {
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
for (const row of mqRows || []) {
|
||||
const s = scoreMqMatch(call, row);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
best = row;
|
||||
}
|
||||
}
|
||||
if (!best || bestScore < 50) return null;
|
||||
return { ...mqQuality(best), score: bestScore };
|
||||
}
|
||||
|
||||
/** @deprecated use matchMediaQualityToCall */
|
||||
export function matchMediaQualityToLeg(leg, mqRows) {
|
||||
return matchMediaQualityToCall(leg, mqRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find worst Prisma 5-min bucket overlapping [start, end].
|
||||
*/
|
||||
export function worstPrismaBucketForCall(call, appAudio, window) {
|
||||
if (!appAudio?.mos?.values?.length) return null;
|
||||
const startMs = call.start ? new Date(call.start).getTime() : NaN;
|
||||
const endMs = Number.isFinite(startMs)
|
||||
? startMs + (call.duration || 0) * 1000
|
||||
: NaN;
|
||||
if (!Number.isFinite(startMs)) return null;
|
||||
|
||||
const intervalMs = 5 * 60 * 1000;
|
||||
const winStart = new Date(window.startTime).getTime();
|
||||
const idx0 = Math.max(0, Math.floor((startMs - winStart) / intervalMs));
|
||||
const idx1 = Math.max(idx0, Math.ceil((endMs - winStart) / intervalMs));
|
||||
|
||||
const mosVals = appAudio.mos.values;
|
||||
const lossVals = appAudio.loss?.values || [];
|
||||
const jitterVals = appAudio.jitter?.values || [];
|
||||
|
||||
let worstMos = null;
|
||||
let worstLoss = null;
|
||||
let worstJitter = null;
|
||||
for (let i = idx0; i <= idx1 && i < mosVals.length; i++) {
|
||||
const m = mosVals[i];
|
||||
if (typeof m === 'number' && (worstMos == null || m < worstMos)) worstMos = m;
|
||||
const l = lossVals[i];
|
||||
if (typeof l === 'number' && (worstLoss == null || l > worstLoss)) worstLoss = l;
|
||||
const j = jitterVals[i];
|
||||
if (typeof j === 'number' && (worstJitter == null || j > worstJitter)) worstJitter = j;
|
||||
}
|
||||
|
||||
if (worstMos == null && worstLoss == null && worstJitter == null) return null;
|
||||
return {
|
||||
siteLevelApprox: true,
|
||||
mos: worstMos,
|
||||
loss: worstLoss,
|
||||
jitter: worstJitter,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeDispositions(legs) {
|
||||
const counts = {};
|
||||
for (const leg of legs) {
|
||||
const key = String(leg.disposition || 'unknown').toLowerCase();
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function joinCallQuality({ cdrItems, mqRows, appAudio, window }) {
|
||||
const calls = groupCdrIntoCalls(cdrItems);
|
||||
const joined = calls.map((call) => {
|
||||
const media = matchMediaQualityToCall(call, mqRows);
|
||||
const wan = worstPrismaBucketForCall(call, appAudio, window);
|
||||
return { ...call, media, wan };
|
||||
});
|
||||
|
||||
return {
|
||||
calls: joined,
|
||||
legs: joined, // backward compat for callers expecting .legs
|
||||
summary: summarizeCalls(joined),
|
||||
};
|
||||
}
|
||||
|
||||
export function filterCdrToWindow(items, window) {
|
||||
return (items || []).filter((item) => {
|
||||
const start = cdrStartTime(item);
|
||||
// cdr_feed query params already scope by start time; keep rows when the
|
||||
// report column label differs from our camelCase guesses.
|
||||
if (!start) return true;
|
||||
return isWithinWindow(start, window);
|
||||
});
|
||||
}
|
||||
37
services/voiceReport/storeContext.js
Normal file
37
services/voiceReport/storeContext.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// services/voiceReport/storeContext.js
|
||||
// Resolve Webex location + timezone for a store number.
|
||||
|
||||
import webex from '../../integrations/webex/WebexClient.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { DISPLAY_TIMEZONE } from '../../utils/time.js';
|
||||
import { resolveStoreMainNumber } from '../callTest/storeResolver.js';
|
||||
|
||||
const LOG_SCOPE = 'voicereport:store';
|
||||
|
||||
/**
|
||||
* @param {string} storeNum
|
||||
* @returns {Promise<{ storeNum, personId, locationId, locationName, dialNumber, timeZone, email }>}
|
||||
*/
|
||||
export async function resolveStoreForVoiceReport(storeNum) {
|
||||
const base = await resolveStoreMainNumber(storeNum);
|
||||
let timeZone = DISPLAY_TIMEZONE;
|
||||
|
||||
try {
|
||||
const [profile, location] = await Promise.all([
|
||||
webex.request('GET', `telephony/config/people/${base.personId}`).catch(() => null),
|
||||
base.locationId
|
||||
? webex.request('GET', `telephony/config/locations/${base.locationId}`).catch(() => null)
|
||||
: null,
|
||||
]);
|
||||
timeZone = profile?.timeZone || location?.timeZone || location?.timezone || timeZone;
|
||||
} catch (err) {
|
||||
logger(LOG_SCOPE, `Timezone lookup failed for store ${storeNum}: ${err.message}`, 'debug');
|
||||
}
|
||||
|
||||
const padded = String(storeNum).trim().padStart(5, '0');
|
||||
return {
|
||||
...base,
|
||||
timeZone,
|
||||
email: `ae${padded}@ae.com`,
|
||||
};
|
||||
}
|
||||
159
services/voiceReport/voiceReportService.js
Normal file
159
services/voiceReport/voiceReportService.js
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// services/voiceReport/voiceReportService.js
|
||||
// Compose CDR + Media Quality + Prisma WAN for /voicereport.
|
||||
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getHistoricalCallActivity } from '../phoneService.js';
|
||||
import { findSdwanSiteForStore } from '../../integrations/paloalto/sites.js';
|
||||
import { getAppMetric } from '../../integrations/paloalto/metrics.js';
|
||||
import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js';
|
||||
import { fetchMediaQualityReport } from '../../integrations/webex/reportsClient.js';
|
||||
import {
|
||||
resolveVoiceAppConfig,
|
||||
summarizeAppSeries,
|
||||
} from '../enrichment/sdwanEnrichment.js';
|
||||
import { computeBusinessWindow } from './businessWindow.js';
|
||||
import { resolveStoreForVoiceReport } from './storeContext.js';
|
||||
import { filterCdrToWindow, joinCallQuality } from './joinCallQuality.js';
|
||||
import { cdrStartTime } from '../cdrFeedParser.js';
|
||||
|
||||
const LOG_SCOPE = 'voicereport:service';
|
||||
|
||||
async function fetchCdrForWindow(store, window) {
|
||||
const cdr = await getHistoricalCallActivity(store.personId, 12, {
|
||||
locationName: store.locationName,
|
||||
startTime: window.startTime,
|
||||
endTime: window.endTime,
|
||||
returnRawItems: true,
|
||||
skipPersonFilter: true,
|
||||
});
|
||||
if (!cdr.available) {
|
||||
logger(
|
||||
LOG_SCOPE,
|
||||
`CDR unavailable for store=${store.storeNum} loc=${JSON.stringify(store.locationName)}: ${cdr.reason}`,
|
||||
'warn',
|
||||
);
|
||||
return { available: false, reason: cdr.reason, items: [], fetchErrors: cdr.fetchErrors };
|
||||
}
|
||||
const beforeFilter = cdr.rawItems?.length || 0;
|
||||
const items = filterCdrToWindow(cdr.rawItems || [], window);
|
||||
const rawCount = cdr.rawCount ?? beforeFilter;
|
||||
if (rawCount > 0 && items.length === 0) {
|
||||
const sample = (cdr.rawItems || []).slice(0, 2).map((r) => cdrStartTime(r) || 'no-start-field');
|
||||
logger(
|
||||
LOG_SCOPE,
|
||||
`CDR window filter dropped all ${rawCount} record(s) for store=${store.storeNum} ` +
|
||||
`(window ${window.startTime}..${window.endTime}); sample start fields: ${sample.join(', ')}`,
|
||||
'warn',
|
||||
);
|
||||
} else {
|
||||
logger(
|
||||
LOG_SCOPE,
|
||||
`CDR store=${store.storeNum} loc=${JSON.stringify(store.locationName)} ` +
|
||||
`api=${rawCount} afterWindowFilter=${items.length}`,
|
||||
'info',
|
||||
);
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
items,
|
||||
rawCount,
|
||||
afterFilterCount: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPrismaAppAudio(storeNum, window) {
|
||||
const voiceCfg = resolveVoiceAppConfig();
|
||||
if (!voiceCfg.appId) {
|
||||
return { available: false, reason: 'PRISMA_APP_ID_VOICE not configured' };
|
||||
}
|
||||
|
||||
const site = await findSdwanSiteForStore(storeNum);
|
||||
if (!site?.id) {
|
||||
return { available: false, reason: 'not a Prisma-managed store' };
|
||||
}
|
||||
|
||||
const opts = { startTime: window.startTime, endTime: window.endTime };
|
||||
const [mosRes, lossRes, jitterRes] = await Promise.all([
|
||||
getAppMetric(site.id, voiceCfg.appId, 'mos', opts),
|
||||
getAppMetric(site.id, voiceCfg.appId, 'loss', opts),
|
||||
getAppMetric(site.id, voiceCfg.appId, 'jitter', opts),
|
||||
]);
|
||||
|
||||
const appAudio = {
|
||||
appId: voiceCfg.appId,
|
||||
appName: voiceCfg.appName,
|
||||
detailsUrl: buildAppDetailsUrl(site.id, voiceCfg.appId),
|
||||
mos: summarizeAppSeries(mosRes, 'AppAudioMos'),
|
||||
loss: summarizeAppSeries(lossRes, 'AppPerfUDPAudioPacketLoss'),
|
||||
jitter: summarizeAppSeries(jitterRes, 'AppPerfUDPAudioJitter'),
|
||||
};
|
||||
|
||||
return { available: true, site, appAudio };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} storeNum
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.dateArg] yesterday | today | YYYY-MM-DD
|
||||
* @param {Date} [opts.now]
|
||||
*/
|
||||
export async function collectVoiceReport(storeNum, opts = {}) {
|
||||
const startedAt = Date.now();
|
||||
const store = await resolveStoreForVoiceReport(storeNum);
|
||||
const window = computeBusinessWindow({
|
||||
timeZone: store.timeZone,
|
||||
dateArg: opts.dateArg,
|
||||
now: opts.now,
|
||||
});
|
||||
|
||||
if (!window.ready) {
|
||||
return {
|
||||
ok: false,
|
||||
store,
|
||||
window,
|
||||
reason: window.reason,
|
||||
};
|
||||
}
|
||||
|
||||
logger(
|
||||
LOG_SCOPE,
|
||||
`Collecting voice report store=${storeNum} loc=${store.locationName} ` +
|
||||
`window=${window.startTime}..${window.endTime}`,
|
||||
'info',
|
||||
);
|
||||
|
||||
const [cdrRes, mqRes, prismaRes] = await Promise.allSettled([
|
||||
fetchCdrForWindow(store, window),
|
||||
fetchMediaQualityReport({ window, locationName: store.locationName }),
|
||||
fetchPrismaAppAudio(storeNum, window),
|
||||
]);
|
||||
|
||||
const cdr = cdrRes.status === 'fulfilled'
|
||||
? cdrRes.value
|
||||
: { available: false, reason: cdrRes.reason?.message, items: [] };
|
||||
const mediaQuality = mqRes.status === 'fulfilled'
|
||||
? mqRes.value
|
||||
: { available: false, reason: mqRes.reason?.message, rows: [] };
|
||||
const prisma = prismaRes.status === 'fulfilled'
|
||||
? prismaRes.value
|
||||
: { available: false, reason: prismaRes.reason?.message };
|
||||
|
||||
const joined = joinCallQuality({
|
||||
cdrItems: cdr.items || [],
|
||||
mqRows: mediaQuality.rows || [],
|
||||
appAudio: prisma.appAudio || null,
|
||||
window,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
store,
|
||||
window,
|
||||
cdr,
|
||||
mediaQuality,
|
||||
prisma,
|
||||
joined,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
86
tests/callTest.cdrMatcher.test.js
Normal file
86
tests/callTest.cdrMatcher.test.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// tests/callTest.cdrMatcher.test.js
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
computeCdrWindow,
|
||||
phoneDigits,
|
||||
scoreCdrLeg,
|
||||
pickBestCdrLeg,
|
||||
} from '../services/callTest/cdrMatcher.js';
|
||||
|
||||
test('phoneDigits normalizes US numbers', () => {
|
||||
assert.equal(phoneDigits('+17247795574'), '17247795574');
|
||||
assert.equal(phoneDigits('7247795574'), '17247795574');
|
||||
});
|
||||
|
||||
test('computeCdrWindow clamps to 12h and 5min API lag', () => {
|
||||
const now = Date.now();
|
||||
const session = {
|
||||
createdAt: new Date(now - 10 * 60_000).toISOString(),
|
||||
events: {
|
||||
initiated: new Date(now - 8 * 60_000).toISOString(),
|
||||
completed: new Date(now - 6 * 60_000).toISOString(),
|
||||
},
|
||||
};
|
||||
const w = computeCdrWindow(session, 60_000);
|
||||
const endMs = new Date(w.endTime).getTime();
|
||||
const startMs = new Date(w.startTime).getTime();
|
||||
assert.ok(endMs <= now - 5 * 60 * 1000 + 1000);
|
||||
assert.ok(startMs < endMs);
|
||||
});
|
||||
|
||||
test('pickBestCdrLeg matches inbound store leg from Twilio', () => {
|
||||
const session = {
|
||||
dialNumber: '+17247795574',
|
||||
durationSec: 75,
|
||||
events: { coreStartedAt: '2026-07-23T21:28:09.295Z' },
|
||||
};
|
||||
const items = [
|
||||
{
|
||||
direction: 'INBOUND',
|
||||
calledNumber: '+17247795574',
|
||||
callingNumber: '+15551234567',
|
||||
startTime: '2026-07-23T21:28:08.000Z',
|
||||
durationSeconds: 74,
|
||||
},
|
||||
{
|
||||
direction: 'OUTBOUND',
|
||||
calledNumber: '+19998887777',
|
||||
callingNumber: '+17247795574',
|
||||
durationSeconds: 10,
|
||||
},
|
||||
];
|
||||
const picked = pickBestCdrLeg(items, session);
|
||||
assert.ok(picked);
|
||||
assert.equal(picked.leg.calledNumber, '+17247795574');
|
||||
assert.ok(picked.score >= 100);
|
||||
});
|
||||
|
||||
test('pickBestCdrLeg matches destination even without caller match', () => {
|
||||
const session = {
|
||||
dialNumber: '+17247795574',
|
||||
events: { initiated: '2026-07-23T21:28:09.295Z' },
|
||||
};
|
||||
const items = [
|
||||
{
|
||||
calledNumber: '+17247795574',
|
||||
callingNumber: '+19998887777',
|
||||
startTime: '2026-07-23T21:28:08.000Z',
|
||||
durationSeconds: 77,
|
||||
},
|
||||
];
|
||||
const picked = pickBestCdrLeg(items, session);
|
||||
assert.ok(picked);
|
||||
assert.equal(picked.leg.calledNumber, '+17247795574');
|
||||
});
|
||||
|
||||
test('scoreCdrLeg returns zero for unrelated destination', () => {
|
||||
const session = { dialNumber: '+17247795574', durationSec: 60, events: {} };
|
||||
const score = scoreCdrLeg(
|
||||
{ direction: 'OUTBOUND', calledNumber: '+19998887777', callingNumber: '+11112223333' },
|
||||
session,
|
||||
);
|
||||
assert.equal(score, 0);
|
||||
});
|
||||
|
|
@ -15,11 +15,20 @@ import {
|
|||
getSession,
|
||||
} from '../services/callTest/sessionStore.js';
|
||||
import { renderCallTestResultMarkdown } from '../services/renderers/callTestRenderer.js';
|
||||
import { _clearTwilioEnrichmentTimersForTests } from '../services/callTest/twilioEnrichment.js';
|
||||
import { _clearCdrTimersForTests } from '../services/callTest/cdrMatcher.js';
|
||||
|
||||
const TEST_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
|
||||
function clearCallTestTimers() {
|
||||
_clearPendingFinalizeTimersForTests();
|
||||
_clearTwilioEnrichmentTimersForTests();
|
||||
_clearCdrTimersForTests();
|
||||
}
|
||||
|
||||
test('handleVoiceWebhook: store path marks core on intro', () => {
|
||||
_clearAllSessionsForTests();
|
||||
clearCallTestTimers();
|
||||
createSession(TEST_ID, {
|
||||
mode: 'store',
|
||||
dialNumber: '+12125550100',
|
||||
|
|
@ -40,13 +49,13 @@ test('handleVoiceWebhook: store path marks core on intro', () => {
|
|||
|
||||
test('handleVoiceWebhook done step finalizes without status callback', async () => {
|
||||
_clearAllSessionsForTests();
|
||||
_clearPendingFinalizeTimersForTests();
|
||||
clearCallTestTimers();
|
||||
createSession(TEST_ID, {
|
||||
mode: 'dial',
|
||||
dialNumber: '+12125550100',
|
||||
roomId: 'room-1',
|
||||
reachedCore: true,
|
||||
config: {},
|
||||
config: { twilioEnrich: false, cdrEnrich: false },
|
||||
});
|
||||
|
||||
handleVoiceWebhook(TEST_ID, 'dial', 'outro');
|
||||
|
|
@ -58,16 +67,40 @@ test('handleVoiceWebhook done step finalizes without status callback', async ()
|
|||
const finalized = await tryFinalizeFromVoiceDoneForTests(TEST_ID);
|
||||
assert.equal(finalized.result, 'pass');
|
||||
assert.equal(finalized.status, 'completed');
|
||||
clearCallTestTimers();
|
||||
});
|
||||
|
||||
test('finalize leaves enrichment pending on session', async () => {
|
||||
_clearAllSessionsForTests();
|
||||
clearCallTestTimers();
|
||||
createSession(TEST_ID, {
|
||||
mode: 'dial',
|
||||
dialNumber: '+17247795574',
|
||||
roomId: 'room-1',
|
||||
callSid: 'CAENRICH',
|
||||
reachedCore: true,
|
||||
lastStep: 'done',
|
||||
config: { twilioEnrich: false, cdrEnrich: false },
|
||||
});
|
||||
|
||||
handleVoiceWebhook(TEST_ID, 'dial', 'done');
|
||||
await tryFinalizeFromVoiceDoneForTests(TEST_ID);
|
||||
|
||||
const s = getSession(TEST_ID);
|
||||
assert.equal(s.status, 'completed');
|
||||
assert.deepEqual(s.enrichment, { twilio: {}, cdr: {} });
|
||||
clearCallTestTimers();
|
||||
});
|
||||
|
||||
test('handleStatusCallback: in-progress maps to answered and passes on completed', async () => {
|
||||
_clearAllSessionsForTests();
|
||||
clearCallTestTimers();
|
||||
createSession(TEST_ID, {
|
||||
mode: 'dial',
|
||||
dialNumber: '+12125550100',
|
||||
reachedCore: true,
|
||||
lastStep: 'done',
|
||||
config: {},
|
||||
config: { twilioEnrich: false, cdrEnrich: false },
|
||||
});
|
||||
|
||||
await handleStatusCallback(TEST_ID, {
|
||||
|
|
@ -83,16 +116,18 @@ test('handleStatusCallback: in-progress maps to answered and passes on completed
|
|||
const s = getSession(TEST_ID);
|
||||
assert.ok(s.events.answered);
|
||||
assert.equal(s.result, 'pass');
|
||||
clearCallTestTimers();
|
||||
});
|
||||
|
||||
test('handleStatusCallback: completed with core yields pass in renderer', async () => {
|
||||
_clearAllSessionsForTests();
|
||||
clearCallTestTimers();
|
||||
createSession(TEST_ID, {
|
||||
mode: 'dial',
|
||||
dialNumber: '+12125550100',
|
||||
reachedCore: true,
|
||||
lastStep: 'done',
|
||||
config: {},
|
||||
config: { twilioEnrich: false, cdrEnrich: false },
|
||||
});
|
||||
|
||||
await handleStatusCallback(TEST_ID, {
|
||||
|
|
@ -112,14 +147,16 @@ test('handleStatusCallback: completed with core yields pass in renderer', async
|
|||
const md = renderCallTestResultMarkdown(s);
|
||||
assert.match(md, /PASSED/);
|
||||
assert.match(md, /CA123/);
|
||||
clearCallTestTimers();
|
||||
});
|
||||
|
||||
test('handleStatusCallback: no-answer fails', async () => {
|
||||
_clearAllSessionsForTests();
|
||||
clearCallTestTimers();
|
||||
createSession(TEST_ID, {
|
||||
mode: 'dial',
|
||||
dialNumber: '+12125550100',
|
||||
config: {},
|
||||
config: { twilioEnrich: false, cdrEnrich: false },
|
||||
});
|
||||
|
||||
await handleStatusCallback(TEST_ID, {
|
||||
|
|
@ -131,6 +168,7 @@ test('handleStatusCallback: no-answer fails', async () => {
|
|||
assert.equal(s.result, 'fail');
|
||||
const md = renderCallTestResultMarkdown(s);
|
||||
assert.match(md, /FAILED/);
|
||||
clearCallTestTimers();
|
||||
});
|
||||
|
||||
test('renderCallTestResultMarkdown shows in progress before final result', () => {
|
||||
|
|
|
|||
116
tests/callTest.twilioEnrichment.test.js
Normal file
116
tests/callTest.twilioEnrichment.test.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// tests/callTest.twilioEnrichment.test.js
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
renderCallTestTwilioDetailsMarkdown,
|
||||
renderCallTestTwilioInsightsMarkdown,
|
||||
renderCallTestCdrMatchMarkdown,
|
||||
} from '../services/renderers/callTestRenderer.js';
|
||||
import {
|
||||
scheduleTwilioEnrichment,
|
||||
_clearTwilioEnrichmentTimersForTests,
|
||||
} from '../services/callTest/twilioEnrichment.js';
|
||||
import {
|
||||
createSession,
|
||||
_clearAllSessionsForTests,
|
||||
getSession,
|
||||
} from '../services/callTest/sessionStore.js';
|
||||
|
||||
const TEST_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
|
||||
function clearTimers() {
|
||||
_clearTwilioEnrichmentTimersForTests();
|
||||
}
|
||||
|
||||
test('renderCallTestTwilioDetailsMarkdown shows call fields', () => {
|
||||
const md = renderCallTestTwilioDetailsMarkdown(
|
||||
{ testId: TEST_ID, callSid: 'CA123' },
|
||||
{
|
||||
available: true,
|
||||
data: {
|
||||
sid: 'CA123',
|
||||
status: 'completed',
|
||||
duration: 75,
|
||||
from: '+15550001111',
|
||||
to: '+17247795574',
|
||||
price: '-0.0140',
|
||||
priceUnit: 'USD',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.match(md, /Twilio details/);
|
||||
assert.match(md, /completed/);
|
||||
assert.match(md, /75s/);
|
||||
});
|
||||
|
||||
test('renderCallTestTwilioInsightsMarkdown handles unavailable', () => {
|
||||
const md = renderCallTestTwilioInsightsMarkdown(
|
||||
{ testId: TEST_ID },
|
||||
{ available: false, reason: 'not enabled' },
|
||||
);
|
||||
assert.match(md, /Unavailable/);
|
||||
assert.match(md, /Advanced Features/);
|
||||
});
|
||||
|
||||
test('renderCallTestTwilioInsightsMarkdown shows quality highlights', () => {
|
||||
const md = renderCallTestTwilioInsightsMarkdown(
|
||||
{ testId: TEST_ID },
|
||||
{
|
||||
available: true,
|
||||
summary: { duration: 75, processingState: 'complete' },
|
||||
metrics: { highlights: { maxJitter: 12, maxPacketLoss: 0.5, minMos: 4.1 } },
|
||||
},
|
||||
);
|
||||
assert.match(md, /jitter/i);
|
||||
assert.match(md, /MOS/);
|
||||
});
|
||||
|
||||
test('renderCallTestCdrMatchMarkdown shows matched leg', () => {
|
||||
const md = renderCallTestCdrMatchMarkdown(
|
||||
{ testId: TEST_ID },
|
||||
{
|
||||
available: true,
|
||||
location: 'Store 0782',
|
||||
rawCount: 3,
|
||||
window: { startTime: '2026-07-23T21:00:00.000Z', endTime: '2026-07-23T21:10:00.000Z' },
|
||||
match: {
|
||||
start: '2026-07-23T21:05:00.000Z',
|
||||
direction: 'INBOUND',
|
||||
duration: 75,
|
||||
status: 'SUCCESS',
|
||||
callingNumber: '+15551234567',
|
||||
calledNumber: '+17247795574',
|
||||
score: 120,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.match(md, /CDR match/);
|
||||
assert.match(md, /INBOUND/);
|
||||
assert.match(md, /Match score/);
|
||||
});
|
||||
|
||||
test('scheduleTwilioEnrichment is no-op without callSid', () => {
|
||||
clearTimers();
|
||||
scheduleTwilioEnrichment({ testId: TEST_ID, roomId: 'room-1', config: { twilioEnrich: true } }, async () => {});
|
||||
clearTimers();
|
||||
});
|
||||
|
||||
test('scheduleTwilioEnrichment registers timers for valid session', () => {
|
||||
_clearAllSessionsForTests();
|
||||
clearTimers();
|
||||
createSession(TEST_ID, {
|
||||
callSid: 'CA555',
|
||||
roomId: 'room-1',
|
||||
config: {
|
||||
twilioEnrich: true,
|
||||
twilioDetailsDelayMs: 300_000,
|
||||
twilioInsightsDelayMs: 300_000,
|
||||
},
|
||||
});
|
||||
const session = getSession(TEST_ID);
|
||||
scheduleTwilioEnrichment(session, async () => {});
|
||||
scheduleTwilioEnrichment(session, async () => {});
|
||||
clearTimers();
|
||||
});
|
||||
61
tests/phoneService.cdrFeed.test.js
Normal file
61
tests/phoneService.cdrFeed.test.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractCdrFeedList } from '../services/cdrFeedParser.js';
|
||||
import {
|
||||
cdrDedupKey,
|
||||
cdrStartTime,
|
||||
cdrCallingNumber,
|
||||
} from '../services/cdrFeedParser.js';
|
||||
import { filterCdrToWindow } from '../services/voiceReport/joinCallQuality.js';
|
||||
|
||||
test('extractCdrFeedList handles items array', () => {
|
||||
const rows = [{ id: 1 }, { id: 2 }];
|
||||
assert.deepEqual(extractCdrFeedList({ items: rows }), rows);
|
||||
});
|
||||
|
||||
test('extractCdrFeedList handles numeric-keyed items object', () => {
|
||||
const rows = [{ id: 'a' }, { id: 'b' }];
|
||||
assert.deepEqual(extractCdrFeedList({ items: { 0: rows[0], 1: rows[1] } }), rows);
|
||||
});
|
||||
|
||||
test('extractCdrFeedList handles top-level array', () => {
|
||||
const rows = [{ id: 1 }];
|
||||
assert.deepEqual(extractCdrFeedList(rows), rows);
|
||||
});
|
||||
|
||||
test('extractCdrFeedList handles records alias', () => {
|
||||
const rows = [{ id: 1 }];
|
||||
assert.deepEqual(extractCdrFeedList({ records: rows }), rows);
|
||||
});
|
||||
|
||||
test('cdrField reads report column labels', () => {
|
||||
const row = {
|
||||
'Report ID': 'abc-1',
|
||||
'Start time': '2026-07-23T14:00:00.000Z',
|
||||
'Calling number': '+15551234567',
|
||||
'Called number': '+12122194600',
|
||||
Duration: 42,
|
||||
Direction: 'Inbound',
|
||||
};
|
||||
assert.equal(cdrStartTime(row), '2026-07-23T14:00:00.000Z');
|
||||
assert.equal(cdrCallingNumber(row), '+15551234567');
|
||||
assert.equal(cdrDedupKey(row), 'id:abc-1');
|
||||
});
|
||||
|
||||
test('cdrDedupKey keeps distinct report rows', () => {
|
||||
const a = { 'Report ID': '1', 'Start time': '2026-07-23T14:00:00.000Z' };
|
||||
const b = { 'Report ID': '2', 'Start time': '2026-07-23T15:00:00.000Z' };
|
||||
assert.notEqual(cdrDedupKey(a), cdrDedupKey(b));
|
||||
});
|
||||
|
||||
test('filterCdrToWindow keeps API rows when start time is report-labeled', () => {
|
||||
const window = {
|
||||
startTime: '2026-07-23T13:00:00.000Z',
|
||||
endTime: '2026-07-24T01:00:00.000Z',
|
||||
};
|
||||
const rows = [
|
||||
{ 'Start time': '2026-07-23T14:00:00.000Z', 'Report ID': '1' },
|
||||
{ 'Start time': '2026-07-23T15:00:00.000Z', 'Report ID': '2' },
|
||||
];
|
||||
assert.equal(filterCdrToWindow(rows, window).length, 2);
|
||||
});
|
||||
59
tests/voiceReport.businessWindow.test.js
Normal file
59
tests/voiceReport.businessWindow.test.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// tests/voiceReport.businessWindow.test.js
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import {
|
||||
computeBusinessWindow,
|
||||
parseReportDateArg,
|
||||
} from '../services/voiceReport/businessWindow.js';
|
||||
|
||||
test('parseReportDateArg defaults to yesterday', () => {
|
||||
const now = DateTime.fromISO('2026-07-24T15:00:00', { zone: 'America/New_York' }).toJSDate();
|
||||
const p = parseReportDateArg(null, 'America/New_York', now);
|
||||
assert.equal(p.label, '2026-07-23');
|
||||
assert.equal(p.mode, 'fullDay');
|
||||
});
|
||||
|
||||
test('computeBusinessWindow full day yesterday 9-9', () => {
|
||||
const now = DateTime.fromISO('2026-07-24T12:00:00', { zone: 'America/New_York' }).toJSDate();
|
||||
const w = computeBusinessWindow({
|
||||
timeZone: 'America/New_York',
|
||||
dateArg: 'yesterday',
|
||||
now,
|
||||
});
|
||||
assert.equal(w.ready, true);
|
||||
assert.equal(w.label, '2026-07-23');
|
||||
const start = DateTime.fromISO(w.startTime, { zone: 'utc' });
|
||||
const end = DateTime.fromISO(w.endTime, { zone: 'utc' });
|
||||
const startLocal = start.setZone('America/New_York');
|
||||
const endLocal = end.setZone('America/New_York');
|
||||
assert.equal(startLocal.hour, 9);
|
||||
assert.equal(endLocal.hour, 21);
|
||||
});
|
||||
|
||||
test('computeBusinessWindow today ends at now minus lag', () => {
|
||||
const now = DateTime.fromISO('2026-07-24T14:00:00', { zone: 'America/New_York' }).toJSDate();
|
||||
const w = computeBusinessWindow({
|
||||
timeZone: 'America/New_York',
|
||||
dateArg: 'today',
|
||||
now,
|
||||
});
|
||||
assert.equal(w.ready, true);
|
||||
assert.equal(w.mode, 'today');
|
||||
const endMs = new Date(w.endTime).getTime();
|
||||
assert.ok(endMs <= now.getTime() - 5 * 60 * 1000 + 2000);
|
||||
const startLocal = DateTime.fromISO(w.startTime).setZone('America/New_York');
|
||||
assert.equal(startLocal.hour, 9);
|
||||
});
|
||||
|
||||
test('computeBusinessWindow today before 9am not ready', () => {
|
||||
const now = DateTime.fromISO('2026-07-24T08:00:00', { zone: 'America/New_York' }).toJSDate();
|
||||
const w = computeBusinessWindow({
|
||||
timeZone: 'America/New_York',
|
||||
dateArg: 'today',
|
||||
now,
|
||||
});
|
||||
assert.equal(w.ready, false);
|
||||
});
|
||||
97
tests/voiceReport.groupCdrCalls.test.js
Normal file
97
tests/voiceReport.groupCdrCalls.test.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// tests/voiceReport.groupCdrCalls.test.js
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
groupCdrIntoCalls,
|
||||
summarizeCalls,
|
||||
classifyCallDirection,
|
||||
} from '../services/voiceReport/groupCdrCalls.js';
|
||||
|
||||
const SAMPLE_LEGS = [
|
||||
{
|
||||
'Answer time': '2026-07-23T13:06:24.325Z',
|
||||
Answered: 'true',
|
||||
Direction: 'TERMINATING',
|
||||
'Start time': '2026-07-23T13:06:24.316Z',
|
||||
'Call type': 'SIP_INBOUND',
|
||||
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
|
||||
Duration: 184,
|
||||
'Report ID': 'fd1debbf-d2b4-331c-846a-2f2a79de9b08',
|
||||
'User type': 'AutomatedAttendantVideo',
|
||||
User: 'Store 2477',
|
||||
'Called number': '+12122194600',
|
||||
'Calling number': '+17189864017',
|
||||
'Call outcome': 'Success',
|
||||
'Call outcome reason': 'Normal',
|
||||
'Answer indicator': 'Yes',
|
||||
'Auto Attendant Key Pressed': '1',
|
||||
},
|
||||
{
|
||||
'Answer time': '2026-07-23T13:07:14.337Z',
|
||||
Answered: 'true',
|
||||
Direction: 'ORIGINATING',
|
||||
'Start time': '2026-07-23T13:06:26.570Z',
|
||||
'Call type': 'SIP_ENTERPRISE',
|
||||
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
|
||||
Duration: 134,
|
||||
'Report ID': '9541d7ea-8ced-3d39-a8ef-c2fc5e341700',
|
||||
'User type': 'AutomatedAttendantVideo',
|
||||
'Called number': '52477',
|
||||
'Calling number': '+17189864017',
|
||||
'Call outcome': 'Success',
|
||||
'Call outcome reason': 'Normal',
|
||||
'Answer indicator': 'Yes',
|
||||
},
|
||||
{
|
||||
'Answer time': '2026-07-23T13:07:14.337Z',
|
||||
Answered: 'true',
|
||||
Direction: 'TERMINATING',
|
||||
'Start time': '2026-07-23T13:06:26.739Z',
|
||||
'Call type': 'SIP_ENTERPRISE',
|
||||
'Client type': 'WXC_DEVICE',
|
||||
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
|
||||
Duration: 134,
|
||||
'Report ID': '829fcf5b-975c-3f5d-9150-af4743232bd3',
|
||||
'User type': 'User',
|
||||
User: 'Store 02477',
|
||||
Model: 'DBS-210-3PC',
|
||||
'Called number': '52477',
|
||||
'Calling number': '+17189864017',
|
||||
'Call outcome': 'Success',
|
||||
'Call outcome reason': 'Normal',
|
||||
'Answer indicator': 'Yes',
|
||||
'Release time': '2026-07-23T13:09:28.784Z',
|
||||
},
|
||||
];
|
||||
|
||||
test('classifyCallDirection detects inbound from SIP_INBOUND leg', () => {
|
||||
assert.equal(classifyCallDirection(SAMPLE_LEGS), 'inbound');
|
||||
});
|
||||
|
||||
test('groupCdrIntoCalls collapses legs with same correlation ID', () => {
|
||||
const calls = groupCdrIntoCalls(SAMPLE_LEGS);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].legCount, 3);
|
||||
assert.equal(calls[0].direction, 'inbound');
|
||||
assert.equal(calls[0].callingNumber, '+17189864017');
|
||||
assert.equal(calls[0].reachedPhone, true);
|
||||
assert.equal(calls[0].endpointUser, 'Store 02477');
|
||||
assert.equal(calls[0].aaKeyPress, '1');
|
||||
assert.equal(calls[0].normalOutcome, true);
|
||||
});
|
||||
|
||||
test('summarizeCalls aggregates inbound reach stats', () => {
|
||||
const calls = groupCdrIntoCalls(SAMPLE_LEGS);
|
||||
const summary = summarizeCalls(calls);
|
||||
assert.equal(summary.total, 1);
|
||||
assert.equal(summary.inbound, 1);
|
||||
assert.equal(summary.inboundReachedPhone, 1);
|
||||
assert.equal(summary.abnormal, 0);
|
||||
});
|
||||
|
||||
test('legs without correlation ID stay separate', () => {
|
||||
const a = { ...SAMPLE_LEGS[0], 'Correlation ID': 'a', 'Report ID': 'r1' };
|
||||
const b = { ...SAMPLE_LEGS[0], 'Correlation ID': 'b', 'Report ID': 'r2' };
|
||||
assert.equal(groupCdrIntoCalls([a, b]).length, 2);
|
||||
});
|
||||
87
tests/voiceReport.join.test.js
Normal file
87
tests/voiceReport.join.test.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// tests/voiceReport.join.test.js
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
joinCallQuality,
|
||||
matchMediaQualityToCall,
|
||||
worstPrismaBucketForCall,
|
||||
} from '../services/voiceReport/joinCallQuality.js';
|
||||
|
||||
const window = {
|
||||
startTime: '2026-07-23T13:00:00.000Z',
|
||||
endTime: '2026-07-24T01:00:00.000Z',
|
||||
};
|
||||
|
||||
test('matchMediaQualityToCall joins on correlation id', () => {
|
||||
const call = {
|
||||
start: '2026-07-23T14:00:00.000Z',
|
||||
duration: 60,
|
||||
callingNumber: '+15551234567',
|
||||
calledNumber: '+17247795574',
|
||||
correlationId: 'abc-123',
|
||||
};
|
||||
const mq = [{
|
||||
'Correlation ID': 'abc-123',
|
||||
'Start Time': '2026-07-23T14:00:01.000Z',
|
||||
_norm: { correlationid: 'abc-123', starttime: '2026-07-23T14:00:01.000Z', mos: '4.2' },
|
||||
}];
|
||||
const hit = matchMediaQualityToCall(call, mq);
|
||||
assert.ok(hit);
|
||||
assert.equal(hit.mos, 4.2);
|
||||
});
|
||||
|
||||
test('joinCallQuality groups legs into correlated calls', () => {
|
||||
const joined = joinCallQuality({
|
||||
cdrItems: [
|
||||
{
|
||||
direction: 'INBOUND',
|
||||
startTime: '2026-07-23T14:00:00.000Z',
|
||||
durationSeconds: 30,
|
||||
status: 'SUCCESS',
|
||||
callingNumber: '+1',
|
||||
calledNumber: '+2',
|
||||
'Correlation ID': 'corr-1',
|
||||
'Report ID': 'r1',
|
||||
'Call type': 'SIP_INBOUND',
|
||||
'Call outcome': 'Success',
|
||||
'Call outcome reason': 'Normal',
|
||||
},
|
||||
{
|
||||
Direction: 'Outbound',
|
||||
'Start time': '2026-07-23T15:00:00.000Z',
|
||||
Duration: 10,
|
||||
'Answer indicator': 'No',
|
||||
'Report ID': 'r2',
|
||||
'Call type': 'SIP_OUTBOUND',
|
||||
'Call outcome': 'Failed',
|
||||
'Call outcome reason': 'Busy',
|
||||
},
|
||||
],
|
||||
mqRows: [],
|
||||
appAudio: null,
|
||||
window,
|
||||
});
|
||||
assert.equal(joined.summary.total, 2);
|
||||
assert.equal(joined.calls.length, 2);
|
||||
assert.equal(joined.summary.inbound, 1);
|
||||
assert.equal(joined.summary.outbound, 1);
|
||||
assert.equal(joined.summary.abnormal, 1);
|
||||
});
|
||||
|
||||
test('worstPrismaBucketForCall picks low MOS bucket', () => {
|
||||
const leg = {
|
||||
start: '2026-07-23T13:05:00.000Z',
|
||||
duration: 600,
|
||||
};
|
||||
const appAudio = {
|
||||
mos: { values: [4.4, 4.4, 3.1, 4.4, 4.4] },
|
||||
loss: { values: [0, 0, 2, 0, 0] },
|
||||
jitter: { values: [5, 5, 5, 5, 5] },
|
||||
};
|
||||
const w = worstPrismaBucketForCall(leg, appAudio, window);
|
||||
assert.ok(w);
|
||||
assert.equal(w.mos, 3.1);
|
||||
assert.equal(w.siteLevelApprox, true);
|
||||
});
|
||||
70
tests/voiceReport.renderer.test.js
Normal file
70
tests/voiceReport.renderer.test.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// tests/voiceReport.renderer.test.js
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { renderVoiceReportMarkdown } from '../services/renderers/voiceReportRenderer.js';
|
||||
|
||||
test('renderVoiceReportMarkdown summary', () => {
|
||||
const md = renderVoiceReportMarkdown({
|
||||
ok: true,
|
||||
store: { storeNum: '782', locationName: 'Store 0782', dialNumber: '+17247795574' },
|
||||
window: {
|
||||
label: '2026-07-23',
|
||||
mode: 'fullDay',
|
||||
timeZone: 'America/New_York',
|
||||
startTime: '2026-07-23T13:00:00.000Z',
|
||||
endTime: '2026-07-24T01:00:00.000Z',
|
||||
},
|
||||
cdr: { available: true, rawCount: 10 },
|
||||
mediaQuality: { available: false, reason: 'skipped in test' },
|
||||
prisma: { available: false, reason: 'not configured' },
|
||||
joined: {
|
||||
summary: {
|
||||
total: 5,
|
||||
inbound: 3,
|
||||
outbound: 2,
|
||||
inboundReachedPhone: 2,
|
||||
outboundConnected: 2,
|
||||
abnormal: 1,
|
||||
outcomes: { 'Success / Normal': 4, 'Failed / Busy': 1 },
|
||||
},
|
||||
calls: [],
|
||||
},
|
||||
elapsedMs: 42,
|
||||
});
|
||||
assert.match(md, /Voice report/);
|
||||
assert.match(md, /Store 0782/);
|
||||
assert.match(md, /Total calls.*5/);
|
||||
assert.match(md, /Inbound reached a phone/);
|
||||
});
|
||||
|
||||
test('renderVoiceReportMarkdown detail truncates', () => {
|
||||
const calls = Array.from({ length: 30 }, (_, i) => ({
|
||||
start: `2026-07-23T14:${String(i).padStart(2, '0')}:00.000Z`,
|
||||
direction: 'inbound',
|
||||
duration: 30,
|
||||
outcome: 'Success',
|
||||
outcomeReason: 'Normal',
|
||||
callingNumber: '+1',
|
||||
calledNumber: '+2',
|
||||
legCount: 2,
|
||||
reachedPhone: true,
|
||||
normalOutcome: true,
|
||||
}));
|
||||
const md = renderVoiceReportMarkdown({
|
||||
ok: true,
|
||||
store: { storeNum: '782', locationName: 'Store 0782' },
|
||||
window: { label: '2026-07-23', mode: 'fullDay', timeZone: 'America/New_York', startTime: 'x', endTime: 'y' },
|
||||
cdr: { available: true },
|
||||
mediaQuality: { available: false },
|
||||
prisma: { available: false },
|
||||
joined: {
|
||||
summary: { total: 30, inbound: 30, outbound: 0, outcomes: {} },
|
||||
calls,
|
||||
},
|
||||
elapsedMs: 1,
|
||||
}, { detail: true });
|
||||
assert.match(md, /Call detail/);
|
||||
assert.match(md, /more calls/);
|
||||
});
|
||||
51
tests/webex.reportsClient.test.js
Normal file
51
tests/webex.reportsClient.test.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// tests/webex.reportsClient.test.js
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
parseMediaQualityCsv,
|
||||
filterMediaQualityRows,
|
||||
reportDateRangeFromWindow,
|
||||
} from '../integrations/webex/reportsCsv.js';
|
||||
|
||||
test('parseMediaQualityCsv parses header rows', () => {
|
||||
const csv = 'Start Time,Location,MOS\n2026-07-23T14:00:00Z,Store 0782,4.1\n';
|
||||
const { headers, rows } = parseMediaQualityCsv(csv);
|
||||
assert.equal(headers.length, 3);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].MOS, '4.1');
|
||||
});
|
||||
|
||||
test('filterMediaQualityRows filters by location and window', () => {
|
||||
const window = {
|
||||
startTime: '2026-07-23T13:00:00.000Z',
|
||||
endTime: '2026-07-23T18:00:00.000Z',
|
||||
};
|
||||
const rows = [
|
||||
{
|
||||
Location: 'Warrendale - LGW',
|
||||
'Start Time': '2026-07-23T14:00:00.000Z',
|
||||
_norm: { location: 'warrendale - lgw', starttime: '2026-07-23T14:00:00.000Z' },
|
||||
},
|
||||
{
|
||||
Location: 'Other',
|
||||
'Start Time': '2026-07-23T14:00:00.000Z',
|
||||
_norm: { location: 'other', starttime: '2026-07-23T14:00:00.000Z' },
|
||||
},
|
||||
];
|
||||
const filtered = filterMediaQualityRows(rows, {
|
||||
locationName: 'Warrendale - LGW',
|
||||
window,
|
||||
});
|
||||
assert.equal(filtered.length, 1);
|
||||
});
|
||||
|
||||
test('reportDateRangeFromWindow uses ISO date slice', () => {
|
||||
const r = reportDateRangeFromWindow({
|
||||
startTime: '2026-07-23T13:00:00.000Z',
|
||||
endTime: '2026-07-23T23:00:00.000Z',
|
||||
});
|
||||
assert.equal(r.startDate, '2026-07-23');
|
||||
assert.equal(r.endDate, '2026-07-23');
|
||||
});
|
||||
Loading…
Reference in a new issue