fix(voice-call): bind webhook dedupe to verified request identity

This commit is contained in:
Peter Steinberger
2026-02-26 21:43:42 +01:00
parent 5a453eacbd
commit 1aadf26f9a
15 changed files with 329 additions and 74 deletions

View File

@@ -4,6 +4,7 @@ import type {
InitiateCallResult,
PlayTtsInput,
ProviderName,
WebhookParseOptions,
ProviderWebhookParseResult,
StartListeningInput,
StopListeningInput,
@@ -36,7 +37,7 @@ export interface VoiceCallProvider {
* Parse provider-specific webhook payload into normalized events.
* Returns events and optional response to send back to provider.
*/
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult;
parseWebhookEvent(ctx: WebhookContext, options?: WebhookParseOptions): ProviderWebhookParseResult;
/**
* Initiate an outbound call.

View File

@@ -6,6 +6,7 @@ import type {
InitiateCallResult,
NormalizedEvent,
PlayTtsInput,
WebhookParseOptions,
ProviderWebhookParseResult,
StartListeningInput,
StopListeningInput,
@@ -28,7 +29,10 @@ export class MockProvider implements VoiceCallProvider {
return { ok: true };
}
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
parseWebhookEvent(
ctx: WebhookContext,
_options?: WebhookParseOptions,
): ProviderWebhookParseResult {
try {
const payload = JSON.parse(ctx.rawBody);
const events: NormalizedEvent[] = [];

View File

@@ -24,4 +24,26 @@ describe("PlivoProvider", () => {
expect(result.providerResponseBody).toContain("<Wait");
expect(result.providerResponseBody).toContain('length="300"');
});
it("uses verified request key when provided", () => {
const provider = new PlivoProvider({
authId: "MA000000000000000000",
authToken: "test-token",
});
const result = provider.parseWebhookEvent(
{
headers: { host: "example.com", "x-plivo-signature-v3-nonce": "nonce-1" },
rawBody:
"CallUUID=call-uuid&CallStatus=in-progress&Direction=outbound&From=%2B15550000000&To=%2B15550000001&Event=StartApp",
url: "https://example.com/voice/webhook?provider=plivo&flow=answer&callId=internal-call-id",
method: "POST",
query: { provider: "plivo", flow: "answer", callId: "internal-call-id" },
},
{ verifiedRequestKey: "plivo:v3:verified" },
);
expect(result.events).toHaveLength(1);
expect(result.events[0]?.dedupeKey).toBe("plivo:v3:verified");
});
});

View File

@@ -1,4 +1,5 @@
import crypto from "node:crypto";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
import type {
HangupCallInput,
@@ -10,6 +11,7 @@ import type {
StartListeningInput,
StopListeningInput,
WebhookContext,
WebhookParseOptions,
WebhookVerificationResult,
} from "../types.js";
import { escapeXml } from "../voice-mapping.js";
@@ -60,6 +62,7 @@ export class PlivoProvider implements VoiceCallProvider {
private readonly authToken: string;
private readonly baseUrl: string;
private readonly options: PlivoProviderOptions;
private readonly apiHost: string;
// Best-effort mapping between create-call request UUID and call UUID.
private requestUuidToCallUuid = new Map<string, string>();
@@ -82,6 +85,7 @@ export class PlivoProvider implements VoiceCallProvider {
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;
}
@@ -92,25 +96,33 @@ export class PlivoProvider implements VoiceCallProvider {
allowNotFound?: boolean;
}): Promise<T> {
const { method, endpoint, body, allowNotFound } = params;
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method,
headers: {
Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
"Content-Type": "application/json",
const { response, release } = await fetchWithSsrFGuard({
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,
},
body: body ? JSON.stringify(body) : undefined,
policy: { allowedHostnames: [this.apiHost] },
auditContext: "voice-call.plivo.api",
});
if (!response.ok) {
if (allowNotFound && response.status === 404) {
return undefined as T;
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 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);
const text = await response.text();
return text ? (JSON.parse(text) as T) : (undefined as T);
} finally {
await release();
}
}
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
@@ -127,10 +139,18 @@ export class PlivoProvider implements VoiceCallProvider {
console.warn(`[plivo] Webhook verification failed: ${result.reason}`);
}
return { ok: result.ok, reason: result.reason, isReplay: result.isReplay };
return {
ok: result.ok,
reason: result.reason,
isReplay: result.isReplay,
verifiedRequestKey: result.verifiedRequestKey,
};
}
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
parseWebhookEvent(
ctx: WebhookContext,
options?: WebhookParseOptions,
): ProviderWebhookParseResult {
const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
const parsed = this.parseBody(ctx.rawBody);
@@ -196,7 +216,7 @@ export class PlivoProvider implements VoiceCallProvider {
// Normal events.
const callIdFromQuery = this.getCallIdFromQuery(ctx);
const dedupeKey = createPlivoRequestDedupeKey(ctx);
const dedupeKey = options?.verifiedRequestKey ?? createPlivoRequestDedupeKey(ctx);
const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey);
return {

View File

@@ -133,7 +133,34 @@ describe("TelnyxProvider.verifyWebhook", () => {
expect(first.ok).toBe(true);
expect(first.isReplay).toBeFalsy();
expect(first.verifiedRequestKey).toBeTruthy();
expect(second.ok).toBe(true);
expect(second.isReplay).toBe(true);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
});
});
describe("TelnyxProvider.parseWebhookEvent", () => {
it("uses verified request key for manager dedupe", () => {
const provider = new TelnyxProvider({
apiKey: "KEY123",
connectionId: "CONN456",
publicKey: undefined,
});
const result = provider.parseWebhookEvent(
createCtx({
rawBody: JSON.stringify({
data: {
id: "evt-123",
event_type: "call.initiated",
payload: { call_control_id: "call-1" },
},
}),
}),
{ verifiedRequestKey: "telnyx:req:abc" },
);
expect(result.events).toHaveLength(1);
expect(result.events[0]?.dedupeKey).toBe("telnyx:req:abc");
});
});

View File

@@ -1,4 +1,5 @@
import crypto from "node:crypto";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
import type { TelnyxConfig } from "../config.js";
import type {
EndReason,
@@ -11,6 +12,7 @@ import type {
StartListeningInput,
StopListeningInput,
WebhookContext,
WebhookParseOptions,
WebhookVerificationResult,
} from "../types.js";
import { verifyTelnyxWebhook } from "../webhook-security.js";
@@ -35,6 +37,7 @@ export class TelnyxProvider implements VoiceCallProvider {
private readonly publicKey: string | undefined;
private readonly options: TelnyxProviderOptions;
private readonly baseUrl = "https://api.telnyx.com/v2";
private readonly apiHost = "api.telnyx.com";
constructor(config: TelnyxConfig, options: TelnyxProviderOptions = {}) {
if (!config.apiKey) {
@@ -58,25 +61,33 @@ export class TelnyxProvider implements VoiceCallProvider {
body: Record<string, unknown>,
options?: { allowNotFound?: boolean },
): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
const { response, release } = await fetchWithSsrFGuard({
url: `${this.baseUrl}${endpoint}`,
init: {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
body: JSON.stringify(body),
policy: { allowedHostnames: [this.apiHost] },
auditContext: "voice-call.telnyx.api",
});
if (!response.ok) {
if (options?.allowNotFound && response.status === 404) {
return undefined as T;
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 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);
const text = await response.text();
return text ? (JSON.parse(text) as T) : (undefined as T);
} finally {
await release();
}
}
/**
@@ -87,13 +98,21 @@ export class TelnyxProvider implements VoiceCallProvider {
skipVerification: this.options.skipVerification,
});
return { ok: result.ok, reason: result.reason, isReplay: result.isReplay };
return {
ok: result.ok,
reason: result.reason,
isReplay: result.isReplay,
verifiedRequestKey: result.verifiedRequestKey,
};
}
/**
* Parse Telnyx webhook event into normalized format.
*/
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
parseWebhookEvent(
ctx: WebhookContext,
options?: WebhookParseOptions,
): ProviderWebhookParseResult {
try {
const payload = JSON.parse(ctx.rawBody);
const data = payload.data;
@@ -102,7 +121,7 @@ export class TelnyxProvider implements VoiceCallProvider {
return { events: [], statusCode: 200 };
}
const event = this.normalizeEvent(data);
const event = this.normalizeEvent(data, options?.verifiedRequestKey);
return {
events: event ? [event] : [],
statusCode: 200,
@@ -115,7 +134,7 @@ export class TelnyxProvider implements VoiceCallProvider {
/**
* Convert Telnyx event to normalized event format.
*/
private normalizeEvent(data: TelnyxEvent): NormalizedEvent | null {
private normalizeEvent(data: TelnyxEvent, dedupeKey?: string): NormalizedEvent | null {
// Decode client_state from Base64 (we encode it in initiateCall)
let callId = "";
if (data.payload?.client_state) {
@@ -132,6 +151,7 @@ export class TelnyxProvider implements VoiceCallProvider {
const baseEvent = {
id: data.id || crypto.randomUUID(),
dedupeKey,
callId,
providerCallId: data.payload?.call_control_id,
timestamp: Date.now(),

View File

@@ -60,7 +60,7 @@ describe("TwilioProvider", () => {
expect(result.providerResponseBody).toContain("<Connect>");
});
it("uses a stable dedupeKey for identical request payloads", () => {
it("uses a stable fallback dedupeKey for identical request payloads", () => {
const provider = createProvider();
const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello";
const ctxA = {
@@ -78,10 +78,31 @@ describe("TwilioProvider", () => {
expect(eventA).toBeDefined();
expect(eventB).toBeDefined();
expect(eventA?.id).not.toBe(eventB?.id);
expect(eventA?.dedupeKey).toBe("twilio:idempotency:idem-123");
expect(eventA?.dedupeKey).toContain("twilio:fallback:");
expect(eventA?.dedupeKey).toBe(eventB?.dedupeKey);
});
it("uses verified request key for dedupe and ignores idempotency header changes", () => {
const provider = createProvider();
const rawBody = "CallSid=CA790&Direction=inbound&SpeechResult=hello";
const ctxA = {
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
headers: { "i-twilio-idempotency-token": "idem-a" },
};
const ctxB = {
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
headers: { "i-twilio-idempotency-token": "idem-b" },
};
const eventA = provider.parseWebhookEvent(ctxA, { verifiedRequestKey: "twilio:req:abc" })
.events[0];
const eventB = provider.parseWebhookEvent(ctxB, { verifiedRequestKey: "twilio:req:abc" })
.events[0];
expect(eventA?.dedupeKey).toBe("twilio:req:abc");
expect(eventB?.dedupeKey).toBe("twilio:req:abc");
});
it("keeps turnToken from query on speech events", () => {
const provider = createProvider();
const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", {

View File

@@ -13,6 +13,7 @@ import type {
StartListeningInput,
StopListeningInput,
WebhookContext,
WebhookParseOptions,
WebhookVerificationResult,
} from "../types.js";
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
@@ -31,19 +32,24 @@ function getHeader(
return value;
}
function createTwilioRequestDedupeKey(ctx: WebhookContext): string {
const idempotencyToken = getHeader(ctx.headers, "i-twilio-idempotency-token");
if (idempotencyToken) {
return `twilio:idempotency:${idempotencyToken}`;
function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string {
if (verifiedRequestKey) {
return verifiedRequestKey;
}
const signature = getHeader(ctx.headers, "x-twilio-signature") ?? "";
const params = new URLSearchParams(ctx.rawBody);
const callSid = params.get("CallSid") ?? "";
const callStatus = params.get("CallStatus") ?? "";
const direction = params.get("Direction") ?? "";
const callId = typeof ctx.query?.callId === "string" ? ctx.query.callId.trim() : "";
const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
const turnToken = typeof ctx.query?.turnToken === "string" ? ctx.query.turnToken.trim() : "";
return `twilio:fallback:${crypto
.createHash("sha256")
.update(`${signature}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`)
.update(
`${signature}\n${callSid}\n${callStatus}\n${direction}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`,
)
.digest("hex")}`;
}
@@ -232,7 +238,10 @@ export class TwilioProvider implements VoiceCallProvider {
/**
* Parse Twilio webhook event into normalized format.
*/
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
parseWebhookEvent(
ctx: WebhookContext,
options?: WebhookParseOptions,
): ProviderWebhookParseResult {
try {
const params = new URLSearchParams(ctx.rawBody);
const callIdFromQuery =
@@ -243,7 +252,7 @@ export class TwilioProvider implements VoiceCallProvider {
typeof ctx.query?.turnToken === "string" && ctx.query.turnToken.trim()
? ctx.query.turnToken.trim()
: undefined;
const dedupeKey = createTwilioRequestDedupeKey(ctx);
const dedupeKey = createTwilioRequestDedupeKey(ctx, options?.verifiedRequestKey);
const event = this.normalizeEvent(params, {
callIdOverride: callIdFromQuery,
dedupeKey,

View File

@@ -29,5 +29,6 @@ export function verifyTwilioProviderWebhook(params: {
ok: result.ok,
reason: result.reason,
isReplay: result.isReplay,
verifiedRequestKey: result.verifiedRequestKey,
};
}