UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

395 lines (394 loc) 13 kB
import { a as normalizeLowercaseStringOrEmpty, c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js"; import "./string-coerce-runtime-CEGJWkQ_.js"; import { t as escapeXml } from "./voice-mapping-sBS7nmKq.js"; import { t as getHeader } from "./http-headers-BKIHh6IS.js"; import { n as reconstructWebhookUrl, r as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-DhCURxbH.js"; import crypto from "node:crypto"; //#region extensions/voice-call/src/providers/plivo.ts function createPlivoRequestDedupeKey(ctx) { const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce"); if (nonceV3) return `plivo:v3:${nonceV3}`; const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce"); if (nonceV2) return `plivo:v2:${nonceV2}`; return `plivo:fallback:${crypto.createHash("sha256").update(ctx.rawBody).digest("hex")}`; } var PlivoProvider = class PlivoProvider { constructor(config, options = {}) { this.name = "plivo"; this.requestUuidToCallUuid = /* @__PURE__ */ new Map(); this.callIdToWebhookUrl = /* @__PURE__ */ new Map(); this.callUuidToWebhookUrl = /* @__PURE__ */ new Map(); this.pendingSpeakByCallId = /* @__PURE__ */ new Map(); this.pendingListenByCallId = /* @__PURE__ */ new Map(); if (!config.authId) throw new Error("Plivo Auth ID is required"); if (!config.authToken) throw new Error("Plivo Auth Token is required"); this.authId = config.authId; this.authToken = config.authToken; this.baseUrl = `https://api.plivo.com/v1/Account/${this.authId}`; this.apiHost = new URL(this.baseUrl).hostname; this.options = options; } async apiRequest(params) { const { method, endpoint, body, allowNotFound } = params; return await guardedJsonApiRequest({ url: `${this.baseUrl}${endpoint}`, method, headers: { Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`, "Content-Type": "application/json" }, body, allowNotFound, allowedHostnames: [this.apiHost], auditContext: "voice-call.plivo.api", errorPrefix: "Plivo API error" }); } verifyWebhook(ctx) { const result = verifyPlivoWebhook(ctx, this.authToken, { publicUrl: this.options.publicUrl, skipVerification: this.options.skipVerification, allowedHosts: this.options.webhookSecurity?.allowedHosts, trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders, trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs, remoteIP: ctx.remoteAddress }); if (!result.ok) console.warn(`[plivo] Webhook verification failed: ${result.reason}`); return { ok: result.ok, reason: result.reason, isReplay: result.isReplay, verifiedRequestKey: result.verifiedRequestKey }; } parseWebhookEvent(ctx, options) { const flow = normalizeOptionalString(ctx.query?.flow) ?? ""; const parsed = this.parseBody(ctx.rawBody); if (!parsed) return { events: [], statusCode: 400 }; const callUuid = parsed.get("CallUUID") || void 0; if (callUuid) { const webhookBase = this.baseWebhookUrlFromCtx(ctx); if (webhookBase) this.callUuidToWebhookUrl.set(callUuid, webhookBase); } if (flow === "xml-speak") { const callId = this.getCallIdFromQuery(ctx); const pending = callId ? this.pendingSpeakByCallId.get(callId) : void 0; if (callId) this.pendingSpeakByCallId.delete(callId); return { events: [], providerResponseBody: pending ? PlivoProvider.xmlSpeak(pending.text, pending.locale) : PlivoProvider.xmlKeepAlive(), providerResponseHeaders: { "Content-Type": "text/xml" }, statusCode: 200 }; } if (flow === "xml-listen") { const callId = this.getCallIdFromQuery(ctx); const pending = callId ? this.pendingListenByCallId.get(callId) : void 0; if (callId) this.pendingListenByCallId.delete(callId); const actionUrl = this.buildActionUrl(ctx, { flow: "getinput", callId }); return { events: [], providerResponseBody: actionUrl && callId ? PlivoProvider.xmlGetInputSpeech({ actionUrl, language: pending?.language }) : PlivoProvider.xmlKeepAlive(), providerResponseHeaders: { "Content-Type": "text/xml" }, statusCode: 200 }; } const callIdFromQuery = this.getCallIdFromQuery(ctx); const dedupeKey = options?.verifiedRequestKey ?? createPlivoRequestDedupeKey(ctx); const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey); return { events: event ? [event] : [], providerResponseBody: flow === "answer" || flow === "getinput" ? PlivoProvider.xmlKeepAlive() : PlivoProvider.xmlEmpty(), providerResponseHeaders: { "Content-Type": "text/xml" }, statusCode: 200 }; } normalizeEvent(params, callIdOverride, dedupeKey) { const callUuid = params.get("CallUUID") || ""; const requestUuid = params.get("RequestUUID") || ""; if (requestUuid && callUuid) this.requestUuidToCallUuid.set(requestUuid, callUuid); const direction = params.get("Direction"); const from = params.get("From") || void 0; const to = params.get("To") || void 0; const callStatus = params.get("CallStatus"); const baseEvent = { id: crypto.randomUUID(), dedupeKey, callId: callIdOverride || callUuid || requestUuid, providerCallId: callUuid || requestUuid || void 0, timestamp: Date.now(), direction: direction === "inbound" ? "inbound" : direction === "outbound" ? "outbound" : void 0, from, to }; const digits = params.get("Digits"); if (digits) return { ...baseEvent, type: "call.dtmf", digits }; const transcript = PlivoProvider.extractTranscript(params); if (transcript) return { ...baseEvent, type: "call.speech", transcript, isFinal: true }; if (callStatus === "ringing") return { ...baseEvent, type: "call.ringing" }; if (callStatus === "in-progress") return { ...baseEvent, type: "call.answered" }; if (callStatus === "completed" || callStatus === "busy" || callStatus === "no-answer" || callStatus === "failed") return { ...baseEvent, type: "call.ended", reason: callStatus === "completed" ? "completed" : callStatus === "busy" ? "busy" : callStatus === "no-answer" ? "no-answer" : "failed" }; if (params.get("Event") === "StartApp" && callUuid) return { ...baseEvent, type: "call.answered" }; return null; } async initiateCall(input) { const webhookUrl = new URL(input.webhookUrl); webhookUrl.searchParams.set("provider", "plivo"); webhookUrl.searchParams.set("callId", input.callId); const answerUrl = new URL(webhookUrl); answerUrl.searchParams.set("flow", "answer"); const hangupUrl = new URL(webhookUrl); hangupUrl.searchParams.set("flow", "hangup"); this.callIdToWebhookUrl.set(input.callId, input.webhookUrl); const ringTimeoutSec = this.options.ringTimeoutSec ?? 30; const result = await this.apiRequest({ method: "POST", endpoint: "/Call/", body: { from: PlivoProvider.normalizeNumber(input.from), to: PlivoProvider.normalizeNumber(input.to), answer_url: answerUrl.toString(), answer_method: "POST", hangup_url: hangupUrl.toString(), hangup_method: "POST", hangup_on_ring: ringTimeoutSec } }); const requestUuid = Array.isArray(result.request_uuid) ? result.request_uuid[0] : result.request_uuid; if (!requestUuid) throw new Error("Plivo call create returned no request_uuid"); return { providerCallId: requestUuid, status: "initiated" }; } async hangupCall(input) { const callUuid = this.requestUuidToCallUuid.get(input.providerCallId); if (callUuid) { await this.apiRequest({ method: "DELETE", endpoint: `/Call/${callUuid}/`, allowNotFound: true }); return; } await this.apiRequest({ method: "DELETE", endpoint: `/Call/${input.providerCallId}/`, allowNotFound: true }); await this.apiRequest({ method: "DELETE", endpoint: `/Request/${input.providerCallId}/`, allowNotFound: true }); } resolveCallContext(params) { const callUuid = this.requestUuidToCallUuid.get(params.providerCallId) ?? params.providerCallId; const webhookBase = this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(params.callId); if (!webhookBase) throw new Error("Missing webhook URL for this call (provider state missing)"); if (!callUuid) throw new Error(`Missing Plivo CallUUID for ${params.operation}`); return { callUuid, webhookBase }; } async transferCallLeg(params) { const transferUrl = new URL(params.webhookBase); transferUrl.searchParams.set("provider", "plivo"); transferUrl.searchParams.set("flow", params.flow); transferUrl.searchParams.set("callId", params.callId); await this.apiRequest({ method: "POST", endpoint: `/Call/${params.callUuid}/`, body: { legs: "aleg", aleg_url: transferUrl.toString(), aleg_method: "POST" } }); } async playTts(input) { const { callUuid, webhookBase } = this.resolveCallContext({ providerCallId: input.providerCallId, callId: input.callId, operation: "playTts" }); this.pendingSpeakByCallId.set(input.callId, { text: input.text, locale: input.locale }); await this.transferCallLeg({ callUuid, webhookBase, callId: input.callId, flow: "xml-speak" }); } async startListening(input) { const { callUuid, webhookBase } = this.resolveCallContext({ providerCallId: input.providerCallId, callId: input.callId, operation: "startListening" }); this.pendingListenByCallId.set(input.callId, { language: input.language }); await this.transferCallLeg({ callUuid, webhookBase, callId: input.callId, flow: "xml-listen" }); } async stopListening(_input) {} async getCallStatus(input) { const terminalStatuses = new Set([ "completed", "busy", "failed", "timeout", "no-answer", "cancel", "machine", "hangup" ]); try { const data = await guardedJsonApiRequest({ url: `${this.baseUrl}/Call/${input.providerCallId}/`, method: "GET", headers: { Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}` }, allowNotFound: true, allowedHostnames: [this.apiHost], auditContext: "plivo-get-call-status", errorPrefix: "Plivo get call status error" }); if (!data) return { status: "not-found", isTerminal: true }; const status = data.call_status ?? "unknown"; return { status, isTerminal: terminalStatuses.has(status) }; } catch { return { status: "error", isTerminal: false, isUnknown: true }; } } static normalizeNumber(numberOrSip) { const trimmed = numberOrSip.trim(); if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("sip:")) return trimmed; return trimmed.replace(/[^\d+]/g, ""); } static xmlEmpty() { return `<?xml version="1.0" encoding="UTF-8"?><Response></Response>`; } static xmlKeepAlive() { return `<?xml version="1.0" encoding="UTF-8"?> <Response> <Wait length="300" /> </Response>`; } static xmlSpeak(text, locale) { return `<?xml version="1.0" encoding="UTF-8"?> <Response> <Speak language="${escapeXml(locale || "en-US")}">${escapeXml(text)}</Speak> <Wait length="300" /> </Response>`; } static xmlGetInputSpeech(params) { const language = params.language || "en-US"; return `<?xml version="1.0" encoding="UTF-8"?> <Response> <GetInput inputType="speech" method="POST" action="${escapeXml(params.actionUrl)}" language="${escapeXml(language)}" executionTimeout="30" speechEndTimeout="1" redirect="false"> </GetInput> <Wait length="300" /> </Response>`; } getCallIdFromQuery(ctx) { return normalizeOptionalString(ctx.query?.callId) || void 0; } buildActionUrl(ctx, opts) { const base = this.baseWebhookUrlFromCtx(ctx); if (!base) return null; const u = new URL(base); u.searchParams.set("provider", "plivo"); u.searchParams.set("flow", opts.flow); if (opts.callId) u.searchParams.set("callId", opts.callId); return u.toString(); } baseWebhookUrlFromCtx(ctx) { try { if (this.options.publicUrl) { const base = new URL(this.options.publicUrl); base.pathname = new URL(ctx.url).pathname; return `${base.origin}${base.pathname}`; } const u = new URL(reconstructWebhookUrl(ctx, { allowedHosts: this.options.webhookSecurity?.allowedHosts, trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders, trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs, remoteIP: ctx.remoteAddress })); return `${u.origin}${u.pathname}`; } catch { return null; } } parseBody(rawBody) { try { return new URLSearchParams(rawBody); } catch { return null; } } static extractTranscript(params) { for (const key of [ "Speech", "Transcription", "TranscriptionText", "SpeechResult", "RecognizedSpeech", "Text" ]) { const value = params.get(key); if (value && value.trim()) return value.trim(); } return null; } }; //#endregion export { PlivoProvider };