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

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

243 lines
7.7 KiB
JavaScript

// integrations/twilio/client.js
// Thin Twilio Voice client for /calltest outbound calls + webhook validation.
// Uses axios (already a project dep) instead of the twilio SDK to keep the
// install surface small.
import axios from 'axios';
import twilio from 'twilio';
import { logger } from '../../utils/logger.js';
const LOG_SCOPE = 'twilio:client';
function requireEnv(name) {
const v = process.env[name];
if (!v || !String(v).trim()) {
throw new Error(`Missing required env: ${name}`);
}
return String(v).trim();
}
export function isTwilioConfigured() {
return !!(
process.env.TWILIO_ACCOUNT_SID
&& process.env.TWILIO_AUTH_TOKEN
&& process.env.TWILIO_FROM_NUMBER
&& process.env.TWILIO_WEBHOOK_BASE_URL
);
}
export function isCallTestEnabled() {
const flag = String(process.env.CALLTEST_ENABLED || '').toLowerCase();
return flag === 'true' || flag === '1' || flag === 'yes';
}
function twilioAuth() {
const accountSid = requireEnv('TWILIO_ACCOUNT_SID');
const authToken = requireEnv('TWILIO_AUTH_TOKEN');
return { accountSid, authToken };
}
/**
* Twilio request signature validation (delegates to the official SDK, which
* handles array params, port variants, and legacy query-string encoding).
* @see https://www.twilio.com/docs/usage/security#validating-requests
*/
export function validateTwilioSignature(signature, url, params) {
const { authToken } = twilioAuth();
if (!signature || !url) return false;
return twilio.validateRequest(authToken, signature, url, params || {});
}
/**
* Place an outbound voice call. Twilio fetches `voiceUrl` when the callee answers.
*/
export async function createOutboundCall({ to, voiceUrl, statusCallback, timeoutSec = 30 }) {
const from = requireEnv('TWILIO_FROM_NUMBER');
const { accountSid, authToken } = twilioAuth();
logger(LOG_SCOPE, `Creating outbound call to ${to} from ${from}`, 'debug');
const body = new URLSearchParams({
To: to,
From: from,
Url: voiceUrl,
Method: 'POST',
StatusCallback: statusCallback,
StatusCallbackMethod: 'POST',
Timeout: String(timeoutSec),
});
for (const event of ['initiated', 'ringing', 'answered', 'completed']) {
body.append('StatusCallbackEvent', event);
}
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls.json`;
const resp = await axios.post(url, body.toString(), {
auth: { username: accountSid, password: authToken },
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 30_000,
});
const call = resp.data;
logger(LOG_SCOPE, `Call created sid=${call.sid} status=${call.status}`, 'debug');
return call;
}
function twilioAxios() {
const { accountSid, authToken } = twilioAuth();
return {
accountSid,
client: axios.create({
auth: { username: accountSid, password: authToken },
timeout: 30_000,
}),
};
}
function unavailable(reason, extra = {}) {
return { available: false, reason, data: null, ...extra };
}
/**
* Fetch the Programmable Voice Call resource (duration, price, timestamps).
*/
export async function fetchCallResource(callSid) {
if (!callSid) return unavailable('no callSid');
try {
const { accountSid, client } = twilioAxios();
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls/${callSid}.json`;
const resp = await client.get(url);
const d = resp.data || {};
return {
available: true,
data: {
sid: d.sid,
status: d.status,
duration: d.duration != null ? Number(d.duration) : null,
startTime: d.start_time || d.date_created,
endTime: d.end_time,
from: d.from,
to: d.to,
direction: d.direction,
price: d.price,
priceUnit: d.price_unit,
answeredBy: d.answered_by,
forwardedFrom: d.forwarded_from,
callerName: d.caller_name,
queueTime: d.queue_time,
},
raw: d,
};
} catch (err) {
const status = err.response?.status;
const msg = err.response?.data?.message || err.message;
logger(LOG_SCOPE, `fetchCallResource ${callSid} failed: ${msg}`, 'warn');
return unavailable(status ? `HTTP ${status}: ${msg}` : msg, { status });
}
}
async function fetchInsightsUrl(path) {
const { client } = twilioAxios();
const url = `https://insights.twilio.com/v1/Voice/${path}`;
const resp = await client.get(url);
return resp.data;
}
/**
* Voice Insights call summary (requires Advanced Features on the account).
*/
export async function fetchCallInsightsSummary(callSid) {
if (!callSid) return unavailable('no callSid');
try {
const data = await fetchInsightsUrl(`${callSid}/Summary`);
return {
available: true,
data: {
callSid: data.call_sid || callSid,
callState: data.call_state,
processingState: data.processing_state,
startTime: data.start_time,
endTime: data.end_time,
duration: data.duration,
connectDuration: data.connect_duration,
from: data.from,
to: data.to,
carrierEdge: data.carrier_edge,
sdkEdge: data.sdk_edge,
tags: data.tags,
attributes: data.attributes,
properties: data.properties,
},
raw: data,
};
} catch (err) {
const status = err.response?.status;
const msg = err.response?.data?.message || err.message;
if (status === 404 || status === 403) {
return unavailable(
status === 403
? 'Voice Insights Advanced Features may not be enabled'
: 'Insights summary not ready or not found',
{ status, retryable: status === 404 },
);
}
logger(LOG_SCOPE, `fetchCallInsightsSummary ${callSid} failed: ${msg}`, 'warn');
return unavailable(status ? `HTTP ${status}: ${msg}` : msg, { status });
}
}
/**
* Voice Insights per-call metrics (carrier_edge for PSTN outbound).
*/
export async function fetchCallInsightsMetrics(callSid, edge = 'carrier_edge') {
if (!callSid) return unavailable('no callSid');
try {
const data = await fetchInsightsUrl(`${callSid}/Metrics?Edge=${encodeURIComponent(edge)}`);
const metrics = Array.isArray(data.metrics) ? data.metrics : [];
const highlights = { maxJitter: null, maxPacketLoss: null, minMos: null };
for (const m of metrics) {
const name = String(m.name || '').toLowerCase();
const vals = m.values || m.samples || [];
for (const v of vals) {
const num = Number(v?.value ?? v?.avg ?? v);
if (!Number.isFinite(num)) continue;
if (name.includes('jitter') && (highlights.maxJitter == null || num > highlights.maxJitter)) {
highlights.maxJitter = num;
}
if (name.includes('packet') && name.includes('loss') && (highlights.maxPacketLoss == null || num > highlights.maxPacketLoss)) {
highlights.maxPacketLoss = num;
}
if (name.includes('mos') && (highlights.minMos == null || num < highlights.minMos)) {
highlights.minMos = num;
}
}
}
return {
available: true,
data: { edge, metrics, highlights },
raw: data,
};
} catch (err) {
const status = err.response?.status;
const msg = err.response?.data?.message || err.message;
if (status === 404 || status === 403) {
return unavailable(
status === 403
? 'Voice Insights Advanced Features may not be enabled'
: 'Insights metrics not ready or not found',
{ status, retryable: status === 404 },
);
}
logger(LOG_SCOPE, `fetchCallInsightsMetrics ${callSid} failed: ${msg}`, 'warn');
return unavailable(status ? `HTTP ${status}: ${msg}` : msg, { status });
}
}
export default {
isTwilioConfigured,
isCallTestEnabled,
createOutboundCall,
validateTwilioSignature,
fetchCallResource,
fetchCallInsightsSummary,
fetchCallInsightsMetrics,
};