diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dfd96f711e..41d3ffa20cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai - Providers/ElevenLabs: omit the MP3-only `Accept` header for PCM telephony synthesis, so Voice Call requests for `pcm_22050` no longer receive MP3 audio. Fixes #67340. Thanks @marcchabot. - Plugins/Voice Call: reap stale pre-answer calls by default, honor configured TTS timeouts for Twilio media-stream playback, and fail empty telephony audio instead of completing as silence. Fixes #42071; supersedes #60957. Thanks @Ryce and @sliekens. - Plugins/Voice Call: terminate expired restored call sessions with the provider and restart restored max-duration timers with only the remaining duration, preventing stale outbound retry loops after Gateway restarts. Fixes #48739. Thanks @mira-solari. +- Plugins/Voice Call: start provider STT after Telnyx outbound conversation greetings and pass configured Telnyx voice IDs through to the speak action. Fixes #56091. Thanks @Roshan. - Skills: honor legacy `metadata.clawdbot` requirements and installer hints when `metadata.openclaw` is absent, so older skills no longer appear ready when required binaries are missing. Fixes #71323. Thanks @chen-zhang-cs-code. - Browser/config: expand `~` in `browser.executablePath` before Chromium launch, so home-relative custom browser paths no longer fail with `ENOENT`. Fixes #67264. Thanks @Quratulain-bilal. - Telegram/streaming: hide tool-progress status updates by default while keeping explicit `streaming.preview.toolProgress` opt-in support for edited preview messages. Fixes #71320. Thanks @neeravmakwana. diff --git a/extensions/voice-call/src/manager.notify.test.ts b/extensions/voice-call/src/manager.notify.test.ts index 14faacb11d4..7bc1bee7469 100644 --- a/extensions/voice-call/src/manager.notify.test.ts +++ b/extensions/voice-call/src/manager.notify.test.ts @@ -245,6 +245,27 @@ describe("CallManager notify and mapping", () => { expectFirstPlayTtsText(provider, "Twilio stream unavailable"); }); + it("starts listening after the initial greeting for Telnyx conversation calls", async () => { + const { manager, provider } = await createManagerHarness({}, new FakeProvider("telnyx")); + + const callId = await initiateCallWithMessage( + manager, + "+15550000012", + "Telnyx hello", + "conversation", + ); + await answerCall(manager, callId, "evt-conversation-telnyx"); + + expectFirstPlayTtsText(provider, "Telnyx hello"); + expect(provider.startListeningCalls).toEqual([ + expect.objectContaining({ + callId, + providerCallId: "call-uuid", + }), + ]); + expect(requireCall(manager, callId).state).toBe("listening"); + }); + it("preserves initialMessage after a failed first playback and retries on next trigger", async () => { const provider = new FailFirstPlayTtsProvider("plivo"); const { manager } = await createManagerHarness({}, provider); diff --git a/extensions/voice-call/src/manager.test-harness.ts b/extensions/voice-call/src/manager.test-harness.ts index b8cdee10011..c992b789506 100644 --- a/extensions/voice-call/src/manager.test-harness.ts +++ b/extensions/voice-call/src/manager.test-harness.ts @@ -19,7 +19,7 @@ import type { } from "./types.js"; export class FakeProvider implements VoiceCallProvider { - readonly name: "plivo" | "twilio"; + readonly name: "plivo" | "twilio" | "telnyx"; twilioStreamConnectEnabled = true; readonly playTtsCalls: PlayTtsInput[] = []; readonly hangupCalls: HangupCallInput[] = []; @@ -27,7 +27,7 @@ export class FakeProvider implements VoiceCallProvider { readonly stopListeningCalls: StopListeningInput[] = []; getCallStatusResult: GetCallStatusResult = { status: "in-progress", isTerminal: false }; - constructor(name: "plivo" | "twilio" = "plivo") { + constructor(name: "plivo" | "twilio" | "telnyx" = "plivo") { this.name = name; } diff --git a/extensions/voice-call/src/manager/outbound.test.ts b/extensions/voice-call/src/manager/outbound.test.ts index e5f807d00b4..bd32794ff8e 100644 --- a/extensions/voice-call/src/manager/outbound.test.ts +++ b/extensions/voice-call/src/manager/outbound.test.ts @@ -226,6 +226,36 @@ describe("voice-call outbound helpers", () => { expect(transitionStateMock).toHaveBeenLastCalledWith(call, "listening"); }); + it("passes configured voice ids through to Telnyx speak", async () => { + const call = { callId: "call-1", providerCallId: "provider-1", state: "active" }; + const playTts = vi.fn(async () => {}); + const ctx = { + activeCalls: new Map([["call-1", call]]), + providerCallIdMap: new Map(), + provider: { name: "telnyx", playTts }, + config: { + tts: { + provider: "telnyx", + providers: { + telnyx: { + voiceId: "Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc", + }, + }, + }, + }, + storePath: "/tmp/voice-call.json", + }; + + await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true }); + + expect(playTts).toHaveBeenCalledWith({ + callId: "call-1", + providerCallId: "provider-1", + text: "hello", + voice: "Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc", + }); + }); + it("sends DTMF through connected provider calls", async () => { const call = { callId: "call-1", providerCallId: "provider-1", state: "active" }; const sendDtmfProvider = vi.fn(async () => {}); diff --git a/extensions/voice-call/src/manager/outbound.ts b/extensions/voice-call/src/manager/outbound.ts index e0b82d73935..c1678498a35 100644 --- a/extensions/voice-call/src/manager/outbound.ts +++ b/extensions/voice-call/src/manager/outbound.ts @@ -213,7 +213,7 @@ export async function speak( transitionState(call, "speaking"); persistCallRecord(ctx.storePath, call); - const voice = provider.name === "twilio" ? resolvePreferredTtsVoice(ctx.config) : undefined; + const voice = resolvePreferredTtsVoice(ctx.config); await provider.playTts({ callId, providerCallId, @@ -233,6 +233,19 @@ export async function speak( } } +function shouldStartListeningAfterInitialMessage(ctx: ConversationContext): boolean { + if (ctx.provider?.name !== "twilio") { + return true; + } + if (!ctx.config.streaming.enabled) { + return true; + } + const streamAwareProvider = ctx.provider as typeof ctx.provider & { + isConversationStreamConnectEnabled?: () => boolean; + }; + return streamAwareProvider.isConversationStreamConnectEnabled?.() !== true; +} + export async function sendDtmf( ctx: SpeakContext, callId: CallId, @@ -316,6 +329,17 @@ export async function speakInitialMessage( await endCall(ctx, call.callId); } }, delaySec * 1000); + } else if ( + mode === "conversation" && + ctx.provider && + shouldStartListeningAfterInitialMessage(ctx) + ) { + transitionState(call, "listening"); + persistCallRecord(ctx.storePath, call); + await ctx.provider.startListening({ + callId: call.callId, + providerCallId, + }); } } finally { ctx.initialMessageInFlight.delete(call.callId); diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts index 0321c2b15f8..380c2ddcec0 100644 --- a/extensions/voice-call/src/providers/telnyx.test.ts +++ b/extensions/voice-call/src/providers/telnyx.test.ts @@ -301,3 +301,40 @@ describe("TelnyxProvider answer control", () => { expect(release).toHaveBeenCalledTimes(1); }); }); + +describe("TelnyxProvider speak control", () => { + it("passes custom Telnyx voice ids to the speak action", 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.playTts({ + callId: "call-1", + providerCallId: "call-control-1", + text: "hello", + voice: "Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc", + }); + + expect(apiMocks.fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.telnyx.com/v2/calls/call-control-1/actions/speak", + auditContext: "voice-call.telnyx.api", + policy: { allowedHostnames: ["api.telnyx.com"] }, + init: expect.objectContaining({ + method: "POST", + body: expect.stringContaining( + '"voice":"Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc"', + ), + }), + }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); +});