// 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, };