1import { createHmac, timingSafeEqual } from "node:crypto"; 2 3const PROVIDER = "ringover"; 4const DEFAULT_TIMEZONE = "Europe/Paris"; 5const DEFAULT_MAX_AGE_SECONDS = 300; 6const DEFAULT_RESPONSE_BUDGET_MS = 1_000; 7const DEFAULT_DEPENDENCY_TIMEOUT_MS = 650; 8const DEFAULT_CONFIGURATION_CACHE_TTL_MS = 30_000; 9const DEFAULT_RESPONSE_RESERVE_MS = 150; 10const COMMERCIAL_SESSION_MAX_AGE_SECONDS = 180; 11const DEFAULT_LIVE_PERFORMANCE_CACHE_TTL_MS = 300_000; 12const DEFAULT_LIVE_PERFORMANCE_QUERY_TIMEOUT_MS = 500; 13const DEFAULT_LIVE_PERFORMANCE_MAX_STALE_MS = 900_000; 14const DEFAULT_EMERGENCY_PREFERRED_CENTRE_ID = "flexylead"; 15const MAX_CONSECUTIVE_PACED_ROUTES = 2; 16const DECISION_TABLE = "call_routing_decisions"; 17const RULE_TABLE = "call_routing_rules"; 18const CAMPAIGN_GROUP_TABLE = "campaign_routing_groups"; 19 20let liveRoutingConfigurationCache = null; 21const livePerformanceScoreCaches = new Map(); 22 23// Tenant/project scoped singleton, provisioned offline. CAS replaces the whole 24// bounded state, atomically reserving a chain AND consuming a provider request. 25// No in-process mutex, unsupported transaction API or hot-path schema changes. 26export const DISPATCH_RESERVATION_TABLE = "ringover_dispatch_reservations"; 27const DISPATCH_MAX_LEASE_MS = 120_000; 28const DISPATCH_REPLAY_MS = 86_400_000; 29const DISPATCH_MAX_CALLS = 4096; 30// The five-second lease margin includes a three-second response-replay window 31// plus transport headroom. Retries never extend the original absolute lease. ··· 164 agents: chosen.map((c, i) => ({ ...c.agent, order: i + 1 })) } 165 : nextStepResponse("Agents déjà en ligne ou réservés"); 166 const ttl = chosen.length ? dispatchLeaseMilliseconds(selected) : 0; 167 // Retain bulky responses only for the bounded replay window. 168 for (const entry of state.calls) if (time > entry.at + DISPATCH_REPLAY_WINDOW_MS) { 169 delete entry.response; delete entry.primaryCommercialId; 170 } 171 state.calls.push({ id: callId, at: time, response: selected, primaryCommercialId: chosen[0]?.commercial || null }); 172 state.leases.push(...chosen.map(c => ({ commercial: c.commercial, callId, since: time, until: time + ttl }))); 173 try { 174 await call("rows_update", { table: DISPATCH_RESERVATION_TABLE, id: 1, 175 expected_revision: String(stored._revision), fields: { state_json: state } }); 176 // Timeout/late writes reserve only until a fixed absolute expiry. Never 177 // compensate blindly: another request may already own the agent. 178 if (now() > time + 1000 || Date.now() >= deadline) throw new Error("Dispatch acknowledgement too late"); 179 return { response: selected, primaryCommercialId: chosen[0]?.commercial || null }; 180 } catch (error) { 181 if (!/revision conflict|row changed since it was loaded/i.test(String(error?.message || error))) throw error; 182 } 183 } 184 throw new Error("Dispatch contention; next IVR step required"); 185} 186 187export default async function handler(event, context = {}) { 188 const startedAt = Date.now(); 189 const config = readConfig(context); 190 const request = normalizeRequest(event); 191 192 try { 193 assertRequest(request); 194 verifyRingoverSignature(request, config); 195 } catch (error) { 196 return structuredResponse(Number(error?.statusCode) || 400, { 197 error: clean(error?.message) || "Requête Ringover refusée", 198 }, startedAt, "rejected"); 199 } 200 201 try { 202 const result = await resolveAndPersist(request.body, context, config, startedAt); 203 return structuredResponse(200, result.response, startedAt, result.mode, { 204 performanceMode: result.performanceMode, 205 performanceStrategy: result.performanceStrategy, 206 performanceRank: result.performanceRank, 207 timings: result.timings, 208 }); 209 } catch (error) { 210 console.error("smart routing fallback", clean(error?.message || error)); 211 // An unexpected error must never discard a caller's ownership constraint. 212 // No verified affinity is available at this boundary: do not send to an 213 // arbitrary configured centre. 214 const fallback = nextStepResponse("Routage indisponible — attribution non vérifiée"); 215 const mode = fallback.agents.length ? "emergency-routing" : "safe-next-step"; 216 return structuredResponse(200, fallback, startedAt, mode); 217 } 218} 219 220export async function resolveAndPersist(body, context, config, startedAt = Date.now()) { 221 const result = await resolveRoutingCandidate(body, context, config, startedAt); 222 if (!config.reservationsEnabled || !result.response?.agents?.length) return result; 223 const call = (tool, args) => dispatchDeadlineCall(context, tool, args, startedAt + config.responseBudgetMs - 30); 224 // One final gate covers normal, sticky, replay and emergency response paths. ··· 3040 context.configurationCacheTtlMs ?? env.RINGOVER_SMART_ROUTING_CONFIGURATION_CACHE_TTL_MS, 3041 DEFAULT_CONFIGURATION_CACHE_TTL_MS, 3042 1_000, 3043 300_000, 3044 ), 3045 emergencyResponse: context.emergencyResponse 3046 ?? emergencyResponseFromEnvironment(env, emergencyPreferredCentreId), 3047 emergencyPreferredCentreId, 3048 fastMode: truthy(context.fastMode ?? env.RINGOVER_SMART_ROUTING_FAST_MODE), 3049 reservationsEnabled: false, // Temporarily disabled at user request: restore concurrent dispatch capacity. 3050 }; 3051} 3052 3053// Prefer a centre-scoped agent list over the legacy free-form response. This 3054// makes the cold-start fallback impossible to configure as a mixed-centre 3055// chain by accident while preserving the old variable as a safe fallback. 3056export function emergencyResponseFromEnvironment(env = {}, preferredCentreId = DEFAULT_EMERGENCY_PREFERRED_CENTRE_ID) { 3057 const preferredAgents = arrayValue(env.RINGOVER_SMART_ROUTING_EMERGENCY_PREFERRED_AGENTS_JSON); 3058 if (preferredAgents.length) { 3059 return { 3060 name: "Routage de secours centre préféré", 3061 destination_centre_id: clean(preferredCentreId) || DEFAULT_EMERGENCY_PREFERRED_CENTRE_ID, 3062 dispatch: "topdown", 3063 max_attempts: 1, 3064 agents: preferredAgents, 3065 }; 3066 } 3067 return objectValue(env.RINGOVER_SMART_ROUTING_EMERGENCY_RESPONSE_JSON); 3068} 3069 3070function verifyRingoverSignature(request, config) { 3071 const v3Signature = header(request.headers, "x-ringover-webhook-signature-v3"); 3072 if (v3Signature) { 3073 const timestamp = clean(header(request.headers, "x-ringover-request-signature-v3-timestamp")); 3074 assertFreshTimestamp(timestamp, config.maxAgeSeconds); 3075 const expected = createHmac("sha256", config.secret) 3076 .update(`POST${config.expectedUrl}${request.rawBody}${timestamp}`) 3077 .digest(); 3078 if (!safeEqual(Buffer.from(v3Signature, "base64"), expected)) { 3079 throw httpError(401, "Signature Ringover V3 invalide"); 3080 } 3081 return; 3082 } 3083 3084 const signature = stripBearer(header(request.headers, "x-ringover-webhook-signature")); 3085 const parts = signature.split("."); 3086 if (parts.length !== 3) throw httpError(401, "Signature Ringover absente ou invalide"); 3087 const [encodedHeader, encodedPayload, encodedSignature] = parts; 3088 const jwtHeader = parseBase64UrlJson(encodedHeader); 3089 if (jwtHeader.alg !== "HS512") throw httpError(401, "Algorithme Ringover refusé"); 3090 const expected = createHmac("sha512", config.secret) 3091 .update(`${encodedHeader}.${encodedPayload}`) 3092 .digest(); 3093 if (!safeEqual(Buffer.from(encodedSignature, "base64url"), expected)) { 3094 throw httpError(401, "Signature Ringover invalide"); 3095 } 3096 const claims = parseBase64UrlJson(encodedPayload); 3097 if (clean(claims.url) !== config.expectedUrl) throw httpError(401, "URL Ringover signée inattendue"); 3098 if (!sameJson(parseSignedPayload(claims.payload), request.body)) { 3099 throw httpError(401, "Payload Ringover signé incohérent"); 3100 } 3101 assertFreshTimestamp(request.body?.timestamp, config.maxAgeSeconds); 3102} 3103 3104function assertRequest(request) { 3105 if (request.method !== "POST") throw httpError(405, "Méthode refusée"); 3106 if (request.body?.event !== "routing" || request.body?.resource !== "call") { 3107 throw httpError(400, "Événement Smart Routing attendu"); 3108 } 3109 if (request.body?.data?.direction !== "inbound") throw httpError(400, "Appel entrant attendu"); 3110 if (!clean(request.body?.data?.call_id)) throw httpError(400, "call_id absent"); 3111 if (!normalizePhone(request.body?.data?.from_number)) throw httpError(400, "Numéro appelant absent"); 3112 if (!normalizePhone(request.body?.data?.to_number)) throw httpError(400, "Numéro appelé absent"); 3113} 3114 3115function normalizeRequest(event) { 3116 const body = event?.body && typeof event.body === "object" ? event.body : event; 3117 return { 3118 method: clean(event?.method || "POST").toUpperCase(), 3119 headers: event?.headers && typeof event.headers === "object" ? event.headers : {}, 3120 rawBody: typeof event?.raw_body === "string" ? event.raw_body : JSON.stringify(body ?? null), 3121 body, 3122 }; 3123} 3124 3125function structuredResponse(statusCode, body, startedAt, mode, diagnostics = {}) {