openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
395 lines (394 loc) • 13 kB
JavaScript
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 };