From 31d8fdb5258709ed4070db1e59e61c16dd1b7348 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 03:23:12 +0100 Subject: [PATCH] fix(voice-call): answer telnyx inbound calls --- CHANGELOG.md | 1 + .../voice-call/src/manager/events.test.ts | 40 +++++++++++++++- extensions/voice-call/src/manager/events.ts | 14 ++++++ extensions/voice-call/src/providers/base.ts | 7 +++ .../voice-call/src/providers/telnyx.test.ts | 47 ++++++++++++++++++- extensions/voice-call/src/providers/telnyx.ts | 10 ++++ extensions/voice-call/src/types.ts | 5 ++ 7 files changed, 122 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf2f67ca36..e8c3397b7f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai - Browser/tool: give Chrome MCP existing-session manage calls a longer default timeout, pass explicit tool timeouts through tab management, and recover stale selected-page MCP sessions instead of forcing a manual reset. Thanks @steipete. - Browser/sandbox: clean up idle tracked tabs opened by primary-agent browser sessions, while preserving active tab reuse and lifecycle cleanup for subagents, cron, and ACP sessions. Fixes #71165. Thanks @dwbutler. - Plugins/Voice Call: reuse the webhook runtime across in-process plugin contexts, avoiding `EADDRINUSE` when agent tools or CLI commands run while the Gateway already owns the voice webhook port. Fixes #58115. Thanks @sfbrian. +- Plugins/Voice Call: answer accepted Telnyx inbound Call Control legs on `call.initiated`, so webhooks that reach OpenClaw no longer leave the caller ringing until hangup. Fixes #58231 and #40131. Thanks @KonsultDigital. - Plugins/Voice Call: pin voice response sessions to `responseModel` before embedded agent runs, avoiding live-session model switch failures when the global default model differs. Fixes #60118. Thanks @xinbenlv. - Media tools: honor the configured web-fetch SSRF policy for media understanding, image/music/video generation references, and PDF inputs, so explicit RFC2544 opt-ins cover WebChat OSS uploads without weakening defaults. Fixes #71300. (#71321) Thanks @neeravmakwana. - Agents/TTS: suppress successful spoken transcripts from verbose chat tool output when structured voice media is already queued, while preserving text output for non-builtin tool-name collisions. Fixes #71282. Thanks @neeravmakwana. diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index 0518ee4ece3..c5d436841c7 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { VoiceCallConfigSchema } from "../config.js"; import type { VoiceCallProvider } from "../providers/base.js"; -import type { HangupCallInput, NormalizedEvent } from "../types.js"; +import type { AnswerCallInput, HangupCallInput, NormalizedEvent } from "../types.js"; import type { CallManagerContext } from "./context.js"; import { processEvent } from "./events.js"; import { flushPendingCallRecordWritesForTest } from "./store.js"; @@ -183,6 +183,44 @@ describe("processEvent (functional)", () => { ]); }); + it("answers accepted inbound calls when the provider requires an answer command", () => { + const answerCalls: AnswerCallInput[] = []; + const provider = createProvider({ + answerCall: async (input: AnswerCallInput): Promise => { + answerCalls.push(input); + }, + }); + const ctx = createContext({ + config: VoiceCallConfigSchema.parse({ + enabled: true, + provider: "telnyx", + fromNumber: "+15550000000", + inboundPolicy: "open", + telnyx: { + apiKey: "KEY123", + connectionId: "CONN456", + }, + skipSignatureVerification: true, + }), + provider, + }); + const event = createInboundInitiatedEvent({ + id: "evt-answer", + providerCallId: "call-control-1", + from: "+15552222222", + }); + + processEvent(ctx, event); + + const call = requireFirstActiveCall(ctx); + expect(answerCalls).toEqual([ + { + callId: call.callId, + providerCallId: "call-control-1", + }, + ]); + }); + it("updates providerCallId map when provider ID changes", () => { const now = Date.now(); const ctx = createContext(); diff --git a/extensions/voice-call/src/manager/events.ts b/extensions/voice-call/src/manager/events.ts index 230641704fd..464e8c2c6fe 100644 --- a/extensions/voice-call/src/manager/events.ts +++ b/extensions/voice-call/src/manager/events.ts @@ -182,6 +182,20 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void { switch (event.type) { case "call.initiated": transitionState(call, "initiated"); + if (call.direction === "inbound" && call.providerCallId && ctx.provider?.answerCall) { + void ctx.provider + .answerCall({ + callId: call.callId, + providerCallId: call.providerCallId, + }) + .catch((err) => { + const message = formatErrorMessage(err); + console.warn( + `[voice-call] Failed to answer inbound call ${call.providerCallId}:`, + message, + ); + }); + } break; case "call.ringing": diff --git a/extensions/voice-call/src/providers/base.ts b/extensions/voice-call/src/providers/base.ts index bfba45e63e7..8319cc8e3fc 100644 --- a/extensions/voice-call/src/providers/base.ts +++ b/extensions/voice-call/src/providers/base.ts @@ -1,4 +1,5 @@ import type { + AnswerCallInput, GetCallStatusInput, GetCallStatusResult, HangupCallInput, @@ -48,6 +49,12 @@ export interface VoiceCallProvider { */ initiateCall(input: InitiateCallInput): Promise; + /** + * Answer an accepted inbound call when the provider requires an explicit + * answer command after the initial webhook. + */ + answerCall?: (input: AnswerCallInput) => Promise; + /** * Hang up an active call. */ diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts index 48ddeed852b..0321c2b15f8 100644 --- a/extensions/voice-call/src/providers/telnyx.test.ts +++ b/extensions/voice-call/src/providers/telnyx.test.ts @@ -1,8 +1,20 @@ import crypto from "node:crypto"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { WebhookContext } from "../types.js"; import { TelnyxProvider } from "./telnyx.js"; +const apiMocks = vi.hoisted(() => ({ + fetchWithSsrFGuard: vi.fn(), +})); + +vi.mock("../../api.js", () => ({ + fetchWithSsrFGuard: apiMocks.fetchWithSsrFGuard, +})); + +afterEach(() => { + apiMocks.fetchWithSsrFGuard.mockReset(); +}); + function createCtx(params?: Partial): WebhookContext { return { headers: {}, @@ -256,3 +268,36 @@ describe("TelnyxProvider.parseWebhookEvent", () => { ); }); }); + +describe("TelnyxProvider answer control", () => { + it("answers inbound call-control legs with a deterministic command id", async () => { + const release = vi.fn(async () => {}); + apiMocks.fetchWithSsrFGuard.mockResolvedValue({ + response: new Response(JSON.stringify({ data: {} }), { status: 200 }), + release, + }); + const provider = new TelnyxProvider({ + apiKey: "KEY123", + connectionId: "CONN456", + publicKey: undefined, + }); + + await provider.answerCall({ + callId: "call-1", + providerCallId: "call-control-1", + }); + + expect(apiMocks.fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.telnyx.com/v2/calls/call-control-1/actions/answer", + auditContext: "voice-call.telnyx.api", + policy: { allowedHostnames: ["api.telnyx.com"] }, + init: expect.objectContaining({ + method: "POST", + body: JSON.stringify({ command_id: "openclaw-answer-call-1" }), + }), + }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/voice-call/src/providers/telnyx.ts b/extensions/voice-call/src/providers/telnyx.ts index 427c749292a..e92d7d5c20c 100644 --- a/extensions/voice-call/src/providers/telnyx.ts +++ b/extensions/voice-call/src/providers/telnyx.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import type { TelnyxConfig } from "../config.js"; import type { + AnswerCallInput, EndReason, GetCallStatusInput, GetCallStatusResult, @@ -280,6 +281,15 @@ export class TelnyxProvider implements VoiceCallProvider { ); } + /** + * Answer an inbound Telnyx Call Control leg. + */ + async answerCall(input: AnswerCallInput): Promise { + await this.apiRequest(`/calls/${input.providerCallId}/actions/answer`, { + command_id: `openclaw-answer-${input.callId}`, + }); + } + /** * Play TTS audio via Telnyx speak action. */ diff --git a/extensions/voice-call/src/types.ts b/extensions/voice-call/src/types.ts index 3963e9d3188..25549919cae 100644 --- a/extensions/voice-call/src/types.ts +++ b/extensions/voice-call/src/types.ts @@ -227,6 +227,11 @@ export type HangupCallInput = { reason: EndReason; }; +export type AnswerCallInput = { + callId: CallId; + providerCallId: ProviderCallId; +}; + export type PlayTtsInput = { callId: CallId; providerCallId: ProviderCallId;