diff --git a/extensions/voice-call/src/http-headers.ts b/extensions/voice-call/src/http-headers.ts new file mode 100644 index 00000000000..1e50658b6bb --- /dev/null +++ b/extensions/voice-call/src/http-headers.ts @@ -0,0 +1,12 @@ +export type HttpHeaderMap = Record; + +export function getHeader(headers: HttpHeaderMap, name: string): string | undefined { + const target = name.toLowerCase(); + const direct = headers[target]; + const value = + direct ?? Object.entries(headers).find(([key]) => key.toLowerCase() === target)?.[1]; + if (Array.isArray(value)) { + return value[0]; + } + return value; +} diff --git a/extensions/voice-call/src/providers/plivo.ts b/extensions/voice-call/src/providers/plivo.ts index aab766e1282..6db603d0639 100644 --- a/extensions/voice-call/src/providers/plivo.ts +++ b/extensions/voice-call/src/providers/plivo.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; import type { PlivoConfig, WebhookSecurityConfig } from "../config.js"; +import { getHeader } from "../http-headers.js"; import type { HangupCallInput, InitiateCallInput, @@ -17,6 +17,7 @@ import type { import { escapeXml } from "../voice-mapping.js"; import { reconstructWebhookUrl, verifyPlivoWebhook } from "../webhook-security.js"; import type { VoiceCallProvider } from "./base.js"; +import { guardedJsonApiRequest } from "./shared/guarded-json-api.js"; export interface PlivoProviderOptions { /** Override public URL origin for signature verification */ @@ -32,17 +33,6 @@ export interface PlivoProviderOptions { type PendingSpeak = { text: string; locale?: string }; type PendingListen = { language?: string }; -function getHeader( - headers: Record, - name: string, -): string | undefined { - const value = headers[name.toLowerCase()]; - if (Array.isArray(value)) { - return value[0]; - } - return value; -} - function createPlivoRequestDedupeKey(ctx: WebhookContext): string { const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce"); if (nonceV3) { @@ -96,33 +86,19 @@ export class PlivoProvider implements VoiceCallProvider { allowNotFound?: boolean; }): Promise { const { method, endpoint, body, allowNotFound } = params; - const { response, release } = await fetchWithSsrFGuard({ + return await guardedJsonApiRequest({ url: `${this.baseUrl}${endpoint}`, - init: { - method, - headers: { - Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`, - "Content-Type": "application/json", - }, - body: body ? JSON.stringify(body) : undefined, + method, + headers: { + Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`, + "Content-Type": "application/json", }, - policy: { allowedHostnames: [this.apiHost] }, + body, + allowNotFound, + allowedHostnames: [this.apiHost], auditContext: "voice-call.plivo.api", + errorPrefix: "Plivo API error", }); - try { - if (!response.ok) { - if (allowNotFound && response.status === 404) { - return undefined as T; - } - const errorText = await response.text(); - throw new Error(`Plivo API error: ${response.status} ${errorText}`); - } - - const text = await response.text(); - return text ? (JSON.parse(text) as T) : (undefined as T); - } finally { - await release(); - } } verifyWebhook(ctx: WebhookContext): WebhookVerificationResult { diff --git a/extensions/voice-call/src/providers/shared/guarded-json-api.ts b/extensions/voice-call/src/providers/shared/guarded-json-api.ts new file mode 100644 index 00000000000..6790cae5d76 --- /dev/null +++ b/extensions/voice-call/src/providers/shared/guarded-json-api.ts @@ -0,0 +1,42 @@ +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; + +type GuardedJsonApiRequestParams = { + url: string; + method: "GET" | "POST" | "DELETE" | "PUT" | "PATCH"; + headers: Record; + body?: Record; + allowNotFound?: boolean; + allowedHostnames: string[]; + auditContext: string; + errorPrefix: string; +}; + +export async function guardedJsonApiRequest( + params: GuardedJsonApiRequestParams, +): Promise { + const { response, release } = await fetchWithSsrFGuard({ + url: params.url, + init: { + method: params.method, + headers: params.headers, + body: params.body ? JSON.stringify(params.body) : undefined, + }, + policy: { allowedHostnames: params.allowedHostnames }, + auditContext: params.auditContext, + }); + + try { + if (!response.ok) { + if (params.allowNotFound && response.status === 404) { + return undefined as T; + } + const errorText = await response.text(); + throw new Error(`${params.errorPrefix}: ${response.status} ${errorText}`); + } + + const text = await response.text(); + return text ? (JSON.parse(text) as T) : (undefined as T); + } finally { + await release(); + } +} diff --git a/extensions/voice-call/src/providers/telnyx.ts b/extensions/voice-call/src/providers/telnyx.ts index 8dbca63746b..80a46ce2192 100644 --- a/extensions/voice-call/src/providers/telnyx.ts +++ b/extensions/voice-call/src/providers/telnyx.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; import type { TelnyxConfig } from "../config.js"; import type { EndReason, @@ -17,6 +16,7 @@ import type { } from "../types.js"; import { verifyTelnyxWebhook } from "../webhook-security.js"; import type { VoiceCallProvider } from "./base.js"; +import { guardedJsonApiRequest } from "./shared/guarded-json-api.js"; /** * Telnyx Voice API provider implementation. @@ -61,33 +61,19 @@ export class TelnyxProvider implements VoiceCallProvider { body: Record, options?: { allowNotFound?: boolean }, ): Promise { - const { response, release } = await fetchWithSsrFGuard({ + return await guardedJsonApiRequest({ url: `${this.baseUrl}${endpoint}`, - init: { - method: "POST", - headers: { - Authorization: `Bearer ${this.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", }, - policy: { allowedHostnames: [this.apiHost] }, + body, + allowNotFound: options?.allowNotFound, + allowedHostnames: [this.apiHost], auditContext: "voice-call.telnyx.api", + errorPrefix: "Telnyx API error", }); - try { - if (!response.ok) { - if (options?.allowNotFound && response.status === 404) { - return undefined as T; - } - const errorText = await response.text(); - throw new Error(`Telnyx API error: ${response.status} ${errorText}`); - } - - const text = await response.text(); - return text ? (JSON.parse(text) as T) : (undefined as T); - } finally { - await release(); - } } /** diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 91862f47769..bf551567722 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import type { TwilioConfig, WebhookSecurityConfig } from "../config.js"; +import { getHeader } from "../http-headers.js"; import type { MediaStreamHandler } from "../media-stream.js"; import { chunkAudio } from "../telephony-audio.js"; import type { TelephonyTtsProvider } from "../telephony-tts.js"; @@ -21,17 +22,6 @@ import type { VoiceCallProvider } from "./base.js"; import { twilioApiRequest } from "./twilio/api.js"; import { verifyTwilioProviderWebhook } from "./twilio/webhook.js"; -function getHeader( - headers: Record, - name: string, -): string | undefined { - const value = headers[name.toLowerCase()]; - if (Array.isArray(value)) { - return value[0]; - } - return value; -} - function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string { if (verifiedRequestKey) { return verifiedRequestKey;