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)
|
# - spark-admin:licenses_read (webexhost, findEmptyLocations)
|
||||||
# - identity:tokens_read (offboarduser: list a user's authorizations)
|
# - identity:tokens_read (offboarduser: list a user's authorizations)
|
||||||
# - identity:tokens_write (offboarduser: revoke 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
|
# The authorizing admin must also hold Full / User / Device Admin role for the
|
||||||
# token-management calls to succeed.
|
# 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).
|
# Optional override for the Webex API base URL (default https://webexapis.com/v1).
|
||||||
# WEBEX_BASE_URL=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
|
# Palo Alto Prisma SD-WAN (formerly CloudGenix) — WAN metrics for /voicediag
|
||||||
# and /phonestatus follow-up
|
# and /phonestatus follow-up
|
||||||
|
|
@ -493,12 +501,30 @@ DECT_RELAY_AGENT_TOKEN=
|
||||||
# CALLTEST_INTRO_TTS=Hello. This is an automated connectivity test...
|
# CALLTEST_INTRO_TTS=Hello. This is an automated connectivity test...
|
||||||
# CALLTEST_OUTRO_TTS=Thank you. The connectivity test is complete. Goodbye.
|
# 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_ENRICH=true
|
||||||
# CALLTEST_CDR_DELAY_MS=360000
|
# CALLTEST_CDR_DELAY_MS=360000
|
||||||
|
# CALLTEST_CDR_MATCH_BUFFER_MS=120000
|
||||||
#
|
#
|
||||||
# Per-store entry overrides: config/calltest-stores.json
|
# 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
|
# Optional. Per-base collect() RPC timeout. Corporate proxies can make
|
||||||
# DBS-210 reads slow; 15s is comfortable, 30s is generous.
|
# DBS-210 reads slow; 15s is comfortable, 30s is generous.
|
||||||
# DECT_COLLECT_TIMEOUT_MS=15000
|
# DECT_COLLECT_TIMEOUT_MS=15000
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
startStoreTest,
|
startStoreTest,
|
||||||
} from '../services/callTest/callTestService.js';
|
} from '../services/callTest/callTestService.js';
|
||||||
import { parseCallTestArgs } from '../services/callTest/parseArgs.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 { extractRequester } from '../utils/requester.js';
|
||||||
import { logger } from '../utils/logger.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).`);
|
await bot.say('markdown', `No in-memory session for testId \`${parsed.testId}\` (expired or unknown).`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await bot.say('markdown', renderCallTestResultMarkdown(session));
|
await bot.say('markdown', renderCallTestResultMarkdown(session) + renderCallTestEnrichmentStatusMarkdown(session));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const SHORT_HELP = {
|
||||||
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
|
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
|
||||||
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
|
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
|
||||||
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix 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)',
|
calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)',
|
||||||
|
|
||||||
// Jira
|
// Jira
|
||||||
|
|
@ -150,6 +151,28 @@ const LONG_HELP = {
|
||||||
'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.',
|
'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: {
|
calltest: {
|
||||||
title: '/calltest',
|
title: '/calltest',
|
||||||
usage: [
|
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).',
|
'**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.',
|
'**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`.',
|
'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`).',
|
'Per-store entry tuning: `config/calltest-stores.json` (`dtmf`, `greetingPauseSec`, `answerWaitSec`).',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -277,7 +301,7 @@ const LONG_HELP = {
|
||||||
|
|
||||||
const GROUPS = [
|
const GROUPS = [
|
||||||
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
|
{ 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: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
|
||||||
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
|
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
|
||||||
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
|
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js';
|
||||||
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
|
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
|
||||||
import { handleTestDevicesByModel } from './testDevicesByModel.js';
|
import { handleTestDevicesByModel } from './testDevicesByModel.js';
|
||||||
import { handleCallTest } from './callTest.js';
|
import { handleCallTest } from './callTest.js';
|
||||||
|
import { handleVoiceReport } from './voiceReport.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Each entry:
|
* Each entry:
|
||||||
|
|
@ -57,6 +58,7 @@ export const commands = [
|
||||||
// (mutating: true). See commands/voiceDiag.js + services/voiceDiag/
|
// (mutating: true). See commands/voiceDiag.js + services/voiceDiag/
|
||||||
// for the full behavior contract + required scopes.
|
// for the full behavior contract + required scopes.
|
||||||
{ name: 'voicediag', handler: handleVoiceDiag, mutating: true },
|
{ name: 'voicediag', handler: handleVoiceDiag, mutating: true },
|
||||||
|
{ name: 'voicereport', handler: handleVoiceReport, mutating: false },
|
||||||
{ name: 'wohistory', handler: handleWoHistory, mutating: false },
|
{ name: 'wohistory', handler: handleWoHistory, mutating: false },
|
||||||
{ name: 'wosummary', handler: handleWoSummary, mutating: false },
|
{ name: 'wosummary', handler: handleWoSummary, mutating: false },
|
||||||
{ name: 'woattachments', handler: handleWoAttachments, 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
|
* shipping default for WAN checks
|
||||||
* @returns {Promise<object|null>}
|
* @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];
|
const spec = APP_METRIC_NAMES[metricKey];
|
||||||
if (!spec) {
|
if (!spec) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -427,6 +427,27 @@ export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 144
|
||||||
return null;
|
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 = {
|
const filter = {
|
||||||
site: [String(siteId)],
|
site: [String(siteId)],
|
||||||
app: [String(appId)],
|
app: [String(appId)],
|
||||||
|
|
@ -436,8 +457,8 @@ export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 144
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await paloAltoAxios.post(appMetricsPath(), {
|
const res = await paloAltoAxios.post(appMetricsPath(), {
|
||||||
start_time: windowStart(windowMinutes),
|
start_time: startTime,
|
||||||
end_time: nowIso(),
|
end_time: endTime,
|
||||||
// NOT pickInterval — see pickAppMetricInterval doc for why:
|
// NOT pickInterval — see pickAppMetricInterval doc for why:
|
||||||
// app metrics need the fine-grained series or the client-side
|
// app metrics need the fine-grained series or the client-side
|
||||||
// worst-window aggregation is meaningless.
|
// worst-window aggregation is meaningless.
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,162 @@ export async function createOutboundCall({ to, voiceUrl, statusCallback, timeout
|
||||||
return call;
|
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 {
|
export default {
|
||||||
isTwilioConfigured,
|
isTwilioConfigured,
|
||||||
isCallTestEnabled,
|
isCallTestEnabled,
|
||||||
createOutboundCall,
|
createOutboundCall,
|
||||||
validateTwilioSignature,
|
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
|
// Convenience wrappers
|
||||||
async getMe() {
|
async getMe() {
|
||||||
return this.request('GET', 'people/me');
|
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",
|
"name": "collabfinder",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"adm-zip": "^0.5.18",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"graphql-request": "^7.4.0",
|
"graphql-request": "^7.4.0",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"twilio": "^6.0.2",
|
"twilio": "^6.0.2",
|
||||||
"webex-node-bot-framework": "^2.5.1",
|
"webex-node-bot-framework": "^2.5.1",
|
||||||
|
|
@ -4517,6 +4519,15 @@
|
||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
|
@ -8687,6 +8698,15 @@
|
||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/makeerror": {
|
||||||
"version": "1.0.12",
|
"version": "1.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,14 @@
|
||||||
"test": "node --test tests/*.test.js"
|
"test": "node --test tests/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"adm-zip": "^0.5.18",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"graphql-request": "^7.4.0",
|
"graphql-request": "^7.4.0",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"twilio": "^6.0.2",
|
"twilio": "^6.0.2",
|
||||||
"webex-node-bot-framework": "^2.5.1",
|
"webex-node-bot-framework": "^2.5.1",
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import {
|
||||||
isCoreStep,
|
isCoreStep,
|
||||||
} from './twiml.js';
|
} from './twiml.js';
|
||||||
import {
|
import {
|
||||||
renderCallTestCdrMarkdown,
|
renderCallTestCdrMatchMarkdown,
|
||||||
renderCallTestResultMarkdown,
|
renderCallTestResultMarkdown,
|
||||||
renderCallTestStartedMarkdown,
|
renderCallTestStartedMarkdown,
|
||||||
} from '../renderers/callTestRenderer.js';
|
} from '../renderers/callTestRenderer.js';
|
||||||
|
|
@ -104,28 +104,17 @@ function computeResult(session) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scheduleCdrEnrichment(session) {
|
async function scheduleCdrEnrichment(session) {
|
||||||
const cfg = getCallTestConfig(session.storeNum);
|
const { scheduleCdrMatchEnrichment } = await import('./cdrMatcher.js');
|
||||||
if (!cfg.cdrEnrich || session.mode !== 'store' || !session.personId) return;
|
scheduleCdrMatchEnrichment(session, {
|
||||||
|
notifyRoom,
|
||||||
const delay = cfg.cdrDelayMs;
|
renderMarkdown: (s, result) => renderCallTestCdrMatchMarkdown(s, result),
|
||||||
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 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) {
|
async function finalizeSession(testId) {
|
||||||
|
|
@ -145,10 +134,7 @@ async function finalizeSession(testId) {
|
||||||
|
|
||||||
if (next?.roomId) {
|
if (next?.roomId) {
|
||||||
await notifyRoom(next.roomId, renderCallTestResultMarkdown(next));
|
await notifyRoom(next.roomId, renderCallTestResultMarkdown(next));
|
||||||
}
|
await schedulePostCallEnrichment(next);
|
||||||
|
|
||||||
if (next?.mode === 'store') {
|
|
||||||
scheduleCdrEnrichment(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 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';
|
return v === 'true' || v === '1' || v === 'yes';
|
||||||
})(),
|
})(),
|
||||||
cdrDelayMs: envInt('CALLTEST_CDR_DELAY_MS', 360_000),
|
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,
|
reachedCore: false,
|
||||||
failedAt: null,
|
failedAt: null,
|
||||||
callSid: null,
|
callSid: null,
|
||||||
|
enrichment: { twilio: {}, cdr: {} },
|
||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
_store.set(testId, session);
|
_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 { logger } from '../utils/logger.js';
|
||||||
import { normalizeMac } from './enrichment/normalizers.js';
|
import { normalizeMac } from './enrichment/normalizers.js';
|
||||||
import { attachMerakiClientWithPorts } from './enrichment/merakiEnrichment.js';
|
import { attachMerakiClientWithPorts } from './enrichment/merakiEnrichment.js';
|
||||||
|
import {
|
||||||
|
extractCdrFeedList,
|
||||||
|
describeCdrFeedPayload,
|
||||||
|
cdrCalledNumber,
|
||||||
|
cdrCallingNumber,
|
||||||
|
cdrDedupKey,
|
||||||
|
cdrDirectionValue,
|
||||||
|
cdrDispositionValue,
|
||||||
|
cdrDurationSeconds,
|
||||||
|
cdrStartTime,
|
||||||
|
} from './cdrFeedParser.js';
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Main public function
|
// 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).
|
// 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.
|
// Secondary client-side filter on person numbers inside the location results.
|
||||||
// (recent activity and historical calls features disabled per request)
|
// (recent activity and historical calls features disabled per request)
|
||||||
export async function getHistoricalCallActivity(personId, hours = 24, options = {}) {
|
/** Last 6 chars of bearer token — enough to confirm curl vs bot use the same credential. */
|
||||||
if (!personId) return { available: false, reason: 'no personId', calls: [], summary: {} };
|
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.
|
// Prefer analytics-calling (user-confirmed working base for cdr_feed). Fall back to the other if needed.
|
||||||
const ANALYTICS_BASES = [
|
const ANALYTICS_BASES = getCdrAnalyticsBases();
|
||||||
'https://analytics-calling.webexapis.com/v1',
|
|
||||||
'https://analytics.webexapis.com/v1'
|
|
||||||
];
|
|
||||||
const CDR_PATH = '/cdr_feed';
|
const CDR_PATH = '/cdr_feed';
|
||||||
const MAX_PER_PAGE = 1000;
|
const MAX_PER_PAGE = 1000;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const person = await webex.request('GET', `people/${personId}`);
|
const person = personId ? await webex.request('GET', `people/${personId}`) : null;
|
||||||
const orgId = person?.orgId;
|
const orgId = person?.orgId;
|
||||||
|
|
||||||
const token = await webex.auth.getAccessToken();
|
const token = await webex.auth.getAccessToken();
|
||||||
const axiosMod = (await import('axios')).default;
|
|
||||||
|
|
||||||
// Get the *required* locations=name from DECT (passed in or self-discover).
|
// Get the *required* locations=name from DECT (passed in or self-discover).
|
||||||
let locationName = providedLocationName;
|
let locationName = providedLocationName;
|
||||||
if (!locationName || locationName === '—') {
|
if (!locationName || locationName === '—') {
|
||||||
|
if (!personId) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: 'no locationName (the /cdr_feed API requires the "locations" param)',
|
||||||
|
calls: [],
|
||||||
|
summary: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const dectNets = await getDectNetworksForPerson(personId);
|
const dectNets = await getDectNetworksForPerson(personId);
|
||||||
if (dectNets.length > 0) {
|
if (dectNets.length > 0) {
|
||||||
|
|
@ -636,6 +689,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
||||||
summary: {},
|
summary: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
locationName = String(locationName).trim();
|
||||||
|
|
||||||
// Person identifiers for secondary filtering inside the location results.
|
// Person identifiers for secondary filtering inside the location results.
|
||||||
const userIds = new Set();
|
const userIds = new Set();
|
||||||
|
|
@ -682,66 +736,65 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
||||||
const FIVE_MIN_MS = 5 * 60 * 1000;
|
const FIVE_MIN_MS = 5 * 60 * 1000;
|
||||||
const TWELVE_HOURS_MS = 12 * 3600 * 1000;
|
const TWELVE_HOURS_MS = 12 * 3600 * 1000;
|
||||||
|
|
||||||
const endMs = Date.now() - FIVE_MIN_MS;
|
let endMs;
|
||||||
const startMs = endMs - TWELVE_HOURS_MS;
|
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 startTime = new Date(startMs).toISOString();
|
||||||
const endTime = new Date(endMs).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 allRawItems = [];
|
||||||
const fetchErrors = [];
|
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 usedBase = null;
|
||||||
let firstRespData = null;
|
let firstRespData = null;
|
||||||
|
let lastHttpStatus = null;
|
||||||
|
|
||||||
for (const base of ANALYTICS_BASES) {
|
for (const base of ANALYTICS_BASES) {
|
||||||
const url = `${base}${CDR_PATH}`;
|
const url = `${base}${CDR_PATH}`;
|
||||||
const params = { startTime, endTime, locations: locationName, max: MAX_PER_PAGE };
|
const params = { startTime, endTime, locations: locationName, max: MAX_PER_PAGE };
|
||||||
const queryStr = new URLSearchParams(params).toString();
|
const queryStr = new URLSearchParams(params).toString();
|
||||||
const fullUrl = `${url}?${queryStr}`;
|
const fullUrl = `${url}?${queryStr}`;
|
||||||
logger('phone:service', `cdr_feed request URL: ${fullUrl}`, 'debug');
|
logger('phone:service', `cdr_feed GET ${fullUrl}`, 'info');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await axiosMod.get(url, {
|
const resp = await webex.analyticsRequestRaw('GET', url, { params, timeout: 15000 });
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
lastHttpStatus = resp.status;
|
||||||
params,
|
|
||||||
timeout: 15000
|
|
||||||
});
|
|
||||||
logger('phone:service', `cdr_feed response status=${resp.status} base=${base}`, 'debug');
|
|
||||||
|
|
||||||
const d = resp.data || {};
|
const d = resp.data || {};
|
||||||
firstRespData = d;
|
firstRespData = d;
|
||||||
|
|
||||||
const list = extractList(d);
|
const list = extractCdrFeedList(d);
|
||||||
allRawItems.push(...list);
|
allRawItems.push(...list);
|
||||||
usedBase = base;
|
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)
|
// Pagination support (up to 10 additional pages per rate limit)
|
||||||
let nextToken = d.next || d['next'] || (d.metadata && d.metadata.next) || null;
|
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;
|
const pageFull = pageParams ? `${pageUrl}?${pageQuery}` : pageUrl;
|
||||||
logger('phone:service', `cdr_feed pagination page ${page} URL: ${pageFull}`, 'debug');
|
logger('phone:service', `cdr_feed pagination page ${page} URL: ${pageFull}`, 'debug');
|
||||||
try {
|
try {
|
||||||
const pResp = await axiosMod.get(pageUrl, {
|
const pResp = await webex.analyticsRequestRaw('GET', pageUrl, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
params: pageParams || undefined,
|
||||||
params: pageParams,
|
timeout: 15000,
|
||||||
timeout: 15000
|
|
||||||
});
|
});
|
||||||
logger('phone:service', `cdr_feed pagination status=${pResp.status}`, 'debug');
|
logger('phone:service', `cdr_feed pagination status=${pResp.status}`, 'debug');
|
||||||
const pd = pResp.data || {};
|
const pd = pResp.data || {};
|
||||||
const pList = extractList(pd);
|
const pList = extractCdrFeedList(pd);
|
||||||
allRawItems.push(...pList);
|
allRawItems.push(...pList);
|
||||||
nextToken = pd.next || pd['next'] || (pd.metadata && pd.metadata.next) || null;
|
nextToken = pd.next || pd['next'] || (pd.metadata && pd.metadata.next) || null;
|
||||||
} catch (pe) {
|
} 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)
|
break; // first successful HTTP response (even if 0 items = empty window is valid)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const st = e.response?.status;
|
const st = e.response?.status;
|
||||||
const bd = e.response?.data || e.message;
|
const errMsg = formatCdrFetchError(e);
|
||||||
logger('phone:service', `cdr_feed ERROR for ${fullUrl} status=${st || 'n/a'}: ${JSON.stringify(bd).slice(0,300)}`, 'debug');
|
const bd = e.response?.data || errMsg;
|
||||||
fetchErrors.push({ base, startTime, endTime, status: st, body: bd });
|
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) {
|
if (allRawItems.length === 0) {
|
||||||
// Legacy fallback (rarely useful now)
|
// Legacy fallback (rarely useful now)
|
||||||
try {
|
try {
|
||||||
const legUrl = `${ANALYTICS_BASES[0]}/callHistory`;
|
const legUrl = `${ANALYTICS_BASES[0]}/callHistory`;
|
||||||
const r = await axiosMod.get(legUrl, {
|
const r = await webex.analyticsRequestRaw('GET', legUrl, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
params: { orgId, personId, startTime, endTime, max: 100 },
|
params: { orgId, personId, startTime, endTime, max: 100 },
|
||||||
timeout: 15000
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
const d = r.data || {};
|
const d = r.data || {};
|
||||||
const li = extractList(d);
|
const li = extractCdrFeedList(d);
|
||||||
if (li.length >= 0) {
|
if (li.length >= 0) {
|
||||||
allRawItems.push(...li);
|
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;
|
const receivedRaw = allRawItems.length;
|
||||||
|
|
@ -808,13 +889,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const items = [];
|
const items = [];
|
||||||
for (const c of allRawItems) {
|
for (const c of allRawItems) {
|
||||||
const key = [
|
const key = cdrDedupKey(c);
|
||||||
c.startTime || c.start || c.callStartTime || '',
|
|
||||||
c.callingNumber || '',
|
|
||||||
c.calledNumber || '',
|
|
||||||
c.duration || c.callDuration || '',
|
|
||||||
c.callId || c.id || c.uuid || ''
|
|
||||||
].join('|');
|
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
items.push(c);
|
items.push(c);
|
||||||
|
|
@ -822,19 +897,27 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secondary person filter inside the location-scoped results.
|
// 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;
|
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: ${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)
|
// Flexible parse (CDR field names vary; covers common report columns)
|
||||||
let inbound = 0, outbound = 0, missed = 0, totalDuration = 0;
|
let inbound = 0, outbound = 0, missed = 0, totalDuration = 0;
|
||||||
const samples = [];
|
const samples = [];
|
||||||
for (const c of effectiveItems) {
|
for (const c of effectiveItems) {
|
||||||
const dir = String(c.direction || c.callDirection || c.callType || c.directionIndicator || '').toUpperCase();
|
const dir = cdrDirectionValue(c);
|
||||||
const dur = Number(c.durationSeconds || c.duration || c.callDuration || c.talkDuration || c.length || 0);
|
const dur = cdrDurationSeconds(c);
|
||||||
const status = String(c.status || c.result || c.callResult || c.callOutcome || c.callStatus || '').toLowerCase();
|
const status = cdrDispositionValue(c).toLowerCase();
|
||||||
|
|
||||||
if (dir.includes('IN') || dir === 'INBOUND' || dir.includes('INCOMING')) inbound++;
|
if (dir.includes('IN') || dir === 'INBOUND' || dir.includes('INCOMING')) inbound++;
|
||||||
else if (dir.includes('OUT') || dir === 'OUTBOUND' || dir.includes('OUTGOING')) outbound++;
|
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) {
|
if (samples.length < 5) {
|
||||||
samples.push({
|
samples.push({
|
||||||
start: c.startTime || c.start || c.callStartTime || c.answerTime || c.releaseTime,
|
start: cdrStartTime(c) || c.releaseTime,
|
||||||
direction: dir || c.direction,
|
direction: dir || c.direction,
|
||||||
duration: dur,
|
duration: dur,
|
||||||
status: c.status || c.result || c.callResult,
|
status: status || cdrDispositionValue(c),
|
||||||
otherParty: c.otherParty || c.calledParty || c.callingParty || c.remoteParty || c.calledNumber || c.callingNumber || 'unknown',
|
otherParty: c.otherParty || c.calledParty || c.callingParty || c.remoteParty
|
||||||
phoneNumber: c.phoneNumber || c.calledNumber || c.callingNumber,
|
|| 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,
|
samples,
|
||||||
rawCount: receivedRaw,
|
rawCount: receivedRaw,
|
||||||
userMatchedCount: userItems.length,
|
userMatchedCount: userItems.length,
|
||||||
|
rawItems: returnRawItems ? effectiveItems : undefined,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err.response?.status;
|
const status = err.response?.status;
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,128 @@ export function renderCallTestResultMarkdown(session) {
|
||||||
return md.trim();
|
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} session
|
||||||
* @param {object} cdr
|
* @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,
|
getSession,
|
||||||
} from '../services/callTest/sessionStore.js';
|
} from '../services/callTest/sessionStore.js';
|
||||||
import { renderCallTestResultMarkdown } from '../services/renderers/callTestRenderer.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';
|
const TEST_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||||
|
|
||||||
|
function clearCallTestTimers() {
|
||||||
|
_clearPendingFinalizeTimersForTests();
|
||||||
|
_clearTwilioEnrichmentTimersForTests();
|
||||||
|
_clearCdrTimersForTests();
|
||||||
|
}
|
||||||
|
|
||||||
test('handleVoiceWebhook: store path marks core on intro', () => {
|
test('handleVoiceWebhook: store path marks core on intro', () => {
|
||||||
_clearAllSessionsForTests();
|
_clearAllSessionsForTests();
|
||||||
|
clearCallTestTimers();
|
||||||
createSession(TEST_ID, {
|
createSession(TEST_ID, {
|
||||||
mode: 'store',
|
mode: 'store',
|
||||||
dialNumber: '+12125550100',
|
dialNumber: '+12125550100',
|
||||||
|
|
@ -40,13 +49,13 @@ test('handleVoiceWebhook: store path marks core on intro', () => {
|
||||||
|
|
||||||
test('handleVoiceWebhook done step finalizes without status callback', async () => {
|
test('handleVoiceWebhook done step finalizes without status callback', async () => {
|
||||||
_clearAllSessionsForTests();
|
_clearAllSessionsForTests();
|
||||||
_clearPendingFinalizeTimersForTests();
|
clearCallTestTimers();
|
||||||
createSession(TEST_ID, {
|
createSession(TEST_ID, {
|
||||||
mode: 'dial',
|
mode: 'dial',
|
||||||
dialNumber: '+12125550100',
|
dialNumber: '+12125550100',
|
||||||
roomId: 'room-1',
|
roomId: 'room-1',
|
||||||
reachedCore: true,
|
reachedCore: true,
|
||||||
config: {},
|
config: { twilioEnrich: false, cdrEnrich: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
handleVoiceWebhook(TEST_ID, 'dial', 'outro');
|
handleVoiceWebhook(TEST_ID, 'dial', 'outro');
|
||||||
|
|
@ -58,16 +67,40 @@ test('handleVoiceWebhook done step finalizes without status callback', async ()
|
||||||
const finalized = await tryFinalizeFromVoiceDoneForTests(TEST_ID);
|
const finalized = await tryFinalizeFromVoiceDoneForTests(TEST_ID);
|
||||||
assert.equal(finalized.result, 'pass');
|
assert.equal(finalized.result, 'pass');
|
||||||
assert.equal(finalized.status, 'completed');
|
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 () => {
|
test('handleStatusCallback: in-progress maps to answered and passes on completed', async () => {
|
||||||
_clearAllSessionsForTests();
|
_clearAllSessionsForTests();
|
||||||
|
clearCallTestTimers();
|
||||||
createSession(TEST_ID, {
|
createSession(TEST_ID, {
|
||||||
mode: 'dial',
|
mode: 'dial',
|
||||||
dialNumber: '+12125550100',
|
dialNumber: '+12125550100',
|
||||||
reachedCore: true,
|
reachedCore: true,
|
||||||
lastStep: 'done',
|
lastStep: 'done',
|
||||||
config: {},
|
config: { twilioEnrich: false, cdrEnrich: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await handleStatusCallback(TEST_ID, {
|
await handleStatusCallback(TEST_ID, {
|
||||||
|
|
@ -83,16 +116,18 @@ test('handleStatusCallback: in-progress maps to answered and passes on completed
|
||||||
const s = getSession(TEST_ID);
|
const s = getSession(TEST_ID);
|
||||||
assert.ok(s.events.answered);
|
assert.ok(s.events.answered);
|
||||||
assert.equal(s.result, 'pass');
|
assert.equal(s.result, 'pass');
|
||||||
|
clearCallTestTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleStatusCallback: completed with core yields pass in renderer', async () => {
|
test('handleStatusCallback: completed with core yields pass in renderer', async () => {
|
||||||
_clearAllSessionsForTests();
|
_clearAllSessionsForTests();
|
||||||
|
clearCallTestTimers();
|
||||||
createSession(TEST_ID, {
|
createSession(TEST_ID, {
|
||||||
mode: 'dial',
|
mode: 'dial',
|
||||||
dialNumber: '+12125550100',
|
dialNumber: '+12125550100',
|
||||||
reachedCore: true,
|
reachedCore: true,
|
||||||
lastStep: 'done',
|
lastStep: 'done',
|
||||||
config: {},
|
config: { twilioEnrich: false, cdrEnrich: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await handleStatusCallback(TEST_ID, {
|
await handleStatusCallback(TEST_ID, {
|
||||||
|
|
@ -112,14 +147,16 @@ test('handleStatusCallback: completed with core yields pass in renderer', async
|
||||||
const md = renderCallTestResultMarkdown(s);
|
const md = renderCallTestResultMarkdown(s);
|
||||||
assert.match(md, /PASSED/);
|
assert.match(md, /PASSED/);
|
||||||
assert.match(md, /CA123/);
|
assert.match(md, /CA123/);
|
||||||
|
clearCallTestTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleStatusCallback: no-answer fails', async () => {
|
test('handleStatusCallback: no-answer fails', async () => {
|
||||||
_clearAllSessionsForTests();
|
_clearAllSessionsForTests();
|
||||||
|
clearCallTestTimers();
|
||||||
createSession(TEST_ID, {
|
createSession(TEST_ID, {
|
||||||
mode: 'dial',
|
mode: 'dial',
|
||||||
dialNumber: '+12125550100',
|
dialNumber: '+12125550100',
|
||||||
config: {},
|
config: { twilioEnrich: false, cdrEnrich: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await handleStatusCallback(TEST_ID, {
|
await handleStatusCallback(TEST_ID, {
|
||||||
|
|
@ -131,6 +168,7 @@ test('handleStatusCallback: no-answer fails', async () => {
|
||||||
assert.equal(s.result, 'fail');
|
assert.equal(s.result, 'fail');
|
||||||
const md = renderCallTestResultMarkdown(s);
|
const md = renderCallTestResultMarkdown(s);
|
||||||
assert.match(md, /FAILED/);
|
assert.match(md, /FAILED/);
|
||||||
|
clearCallTestTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renderCallTestResultMarkdown shows in progress before final result', () => {
|
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