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>
243 lines
7.7 KiB
JavaScript
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,
|
|
};
|