mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 13:01:41 +00:00
fix(voice-call): harden inbound policy
This commit is contained in:
@@ -21,15 +21,21 @@ import type { VoiceCallProvider } from "./base.js";
|
||||
* Uses Telnyx Call Control API v2 for managing calls.
|
||||
* @see https://developers.telnyx.com/docs/api/v2/call-control
|
||||
*/
|
||||
export interface TelnyxProviderOptions {
|
||||
/** Allow unsigned webhooks when no public key is configured */
|
||||
allowUnsignedWebhooks?: boolean;
|
||||
}
|
||||
|
||||
export class TelnyxProvider implements VoiceCallProvider {
|
||||
readonly name = "telnyx" as const;
|
||||
|
||||
private readonly apiKey: string;
|
||||
private readonly connectionId: string;
|
||||
private readonly publicKey: string | undefined;
|
||||
private readonly options: TelnyxProviderOptions;
|
||||
private readonly baseUrl = "https://api.telnyx.com/v2";
|
||||
|
||||
constructor(config: TelnyxConfig) {
|
||||
constructor(config: TelnyxConfig, options: TelnyxProviderOptions = {}) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error("Telnyx API key is required");
|
||||
}
|
||||
@@ -40,6 +46,7 @@ export class TelnyxProvider implements VoiceCallProvider {
|
||||
this.apiKey = config.apiKey;
|
||||
this.connectionId = config.connectionId;
|
||||
this.publicKey = config.publicKey;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,8 +83,14 @@ export class TelnyxProvider implements VoiceCallProvider {
|
||||
*/
|
||||
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
||||
if (!this.publicKey) {
|
||||
// No public key configured, skip verification (not recommended for production)
|
||||
return { ok: true };
|
||||
if (this.options.allowUnsignedWebhooks) {
|
||||
console.warn("[telnyx] Webhook verification skipped (no public key configured)");
|
||||
return { ok: true, reason: "verification skipped (no public key configured)" };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: "Missing telnyx.publicKey (configure to verify webhooks)",
|
||||
};
|
||||
}
|
||||
|
||||
const signature = ctx.headers["telnyx-signature-ed25519"];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { WebhookContext } from "../types.js";
|
||||
import { TwilioProvider } from "./twilio.js";
|
||||
|
||||
const STREAM_URL = "wss://example.ngrok.app/voice/stream";
|
||||
const STREAM_URL_PREFIX = "wss://example.ngrok.app/voice/stream?token=";
|
||||
|
||||
function createProvider(): TwilioProvider {
|
||||
return new TwilioProvider(
|
||||
@@ -24,13 +24,13 @@ function createContext(rawBody: string, query?: WebhookContext["query"]): Webhoo
|
||||
describe("TwilioProvider", () => {
|
||||
it("returns streaming TwiML for outbound conversation calls before in-progress", () => {
|
||||
const provider = createProvider();
|
||||
const ctx = createContext("CallStatus=initiated&Direction=outbound-api", {
|
||||
const ctx = createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA123", {
|
||||
callId: "call-1",
|
||||
});
|
||||
|
||||
const result = provider.parseWebhookEvent(ctx);
|
||||
|
||||
expect(result.providerResponseBody).toContain(STREAM_URL);
|
||||
expect(result.providerResponseBody).toContain(STREAM_URL_PREFIX);
|
||||
expect(result.providerResponseBody).toContain("<Connect>");
|
||||
});
|
||||
|
||||
@@ -50,11 +50,11 @@ describe("TwilioProvider", () => {
|
||||
|
||||
it("returns streaming TwiML for inbound calls", () => {
|
||||
const provider = createProvider();
|
||||
const ctx = createContext("CallStatus=ringing&Direction=inbound");
|
||||
const ctx = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA456");
|
||||
|
||||
const result = provider.parseWebhookEvent(ctx);
|
||||
|
||||
expect(result.providerResponseBody).toContain(STREAM_URL);
|
||||
expect(result.providerResponseBody).toContain(STREAM_URL_PREFIX);
|
||||
expect(result.providerResponseBody).toContain("<Connect>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,8 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
|
||||
/** Map of call SID to stream SID for media streams */
|
||||
private callStreamMap = new Map<string, string>();
|
||||
/** Per-call tokens for media stream authentication */
|
||||
private streamAuthTokens = new Map<string, string>();
|
||||
|
||||
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
|
||||
private readonly twimlStorage = new Map<string, string>();
|
||||
@@ -94,6 +96,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
}
|
||||
|
||||
this.deleteStoredTwiml(callIdMatch[1]);
|
||||
this.streamAuthTokens.delete(providerCallId);
|
||||
}
|
||||
|
||||
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
||||
@@ -138,6 +141,19 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
this.callStreamMap.delete(callSid);
|
||||
}
|
||||
|
||||
isValidStreamToken(callSid: string, token?: string): boolean {
|
||||
const expected = this.streamAuthTokens.get(callSid);
|
||||
if (!expected || !token) {
|
||||
return false;
|
||||
}
|
||||
if (expected.length !== token.length) {
|
||||
const dummy = Buffer.from(expected);
|
||||
crypto.timingSafeEqual(dummy, dummy);
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear TTS queue for a call (barge-in).
|
||||
* Used when user starts speaking to interrupt current TTS playback.
|
||||
@@ -271,11 +287,13 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
case "busy":
|
||||
case "no-answer":
|
||||
case "failed":
|
||||
this.streamAuthTokens.delete(callSid);
|
||||
if (callIdOverride) {
|
||||
this.deleteStoredTwiml(callIdOverride);
|
||||
}
|
||||
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
||||
case "canceled":
|
||||
this.streamAuthTokens.delete(callSid);
|
||||
if (callIdOverride) {
|
||||
this.deleteStoredTwiml(callIdOverride);
|
||||
}
|
||||
@@ -308,6 +326,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
const callStatus = params.get("CallStatus");
|
||||
const direction = params.get("Direction");
|
||||
const isOutbound = direction?.startsWith("outbound") ?? false;
|
||||
const callSid = params.get("CallSid") || undefined;
|
||||
const callIdFromQuery =
|
||||
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
||||
? ctx.query.callId.trim()
|
||||
@@ -330,7 +349,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
|
||||
// Conversation mode: return streaming TwiML immediately for outbound calls.
|
||||
if (isOutbound) {
|
||||
const streamUrl = this.getStreamUrl();
|
||||
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
|
||||
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
|
||||
}
|
||||
}
|
||||
@@ -343,7 +362,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
// Handle subsequent webhook requests (status callbacks, etc.)
|
||||
// For inbound calls, answer immediately with stream
|
||||
if (direction === "inbound") {
|
||||
const streamUrl = this.getStreamUrl();
|
||||
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
|
||||
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
|
||||
}
|
||||
|
||||
@@ -352,7 +371,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
return TwilioProvider.EMPTY_TWIML;
|
||||
}
|
||||
|
||||
const streamUrl = this.getStreamUrl();
|
||||
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
|
||||
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
|
||||
}
|
||||
|
||||
@@ -380,6 +399,27 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
return `${wsOrigin}${path}`;
|
||||
}
|
||||
|
||||
private getStreamAuthToken(callSid: string): string {
|
||||
const existing = this.streamAuthTokens.get(callSid);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const token = crypto.randomBytes(16).toString("base64url");
|
||||
this.streamAuthTokens.set(callSid, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
private getStreamUrlForCall(callSid: string): string | null {
|
||||
const baseUrl = this.getStreamUrl();
|
||||
if (!baseUrl) {
|
||||
return null;
|
||||
}
|
||||
const token = this.getStreamAuthToken(callSid);
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set("token", token);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TwiML to connect a call to a WebSocket media stream.
|
||||
* This enables bidirectional audio streaming for real-time STT/TTS.
|
||||
@@ -444,6 +484,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
this.deleteStoredTwimlForProviderCall(input.providerCallId);
|
||||
|
||||
this.callWebhookUrls.delete(input.providerCallId);
|
||||
this.streamAuthTokens.delete(input.providerCallId);
|
||||
|
||||
await this.apiRequest(
|
||||
`/Calls/${input.providerCallId}.json`,
|
||||
|
||||
Reference in New Issue
Block a user