fix(voice-call): start listening after telnyx greetings · openclaw/openclaw@455e84f
steipete
·
2026-04-25
·
via Recent Commits to openclaw:main
| Original file line number | Diff line number | Diff line change |
|---|
@@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai
|
79 | 79 | - 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. |
80 | 80 | - 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. |
81 | 81 | - 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. |
| 82 | +- 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. |
82 | 83 | - 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. |
83 | 84 | - 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. |
84 | 85 | - 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. |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -245,6 +245,27 @@ describe("CallManager notify and mapping", () => {
|
245 | 245 | expectFirstPlayTtsText(provider, "Twilio stream unavailable"); |
246 | 246 | }); |
247 | 247 | |
| 248 | +it("starts listening after the initial greeting for Telnyx conversation calls", async () => { |
| 249 | +const { manager, provider } = await createManagerHarness({}, new FakeProvider("telnyx")); |
| 250 | + |
| 251 | +const callId = await initiateCallWithMessage( |
| 252 | +manager, |
| 253 | +"+15550000012", |
| 254 | +"Telnyx hello", |
| 255 | +"conversation", |
| 256 | +); |
| 257 | +await answerCall(manager, callId, "evt-conversation-telnyx"); |
| 258 | + |
| 259 | +expectFirstPlayTtsText(provider, "Telnyx hello"); |
| 260 | +expect(provider.startListeningCalls).toEqual([ |
| 261 | +expect.objectContaining({ |
| 262 | + callId, |
| 263 | +providerCallId: "call-uuid", |
| 264 | +}), |
| 265 | +]); |
| 266 | +expect(requireCall(manager, callId).state).toBe("listening"); |
| 267 | +}); |
| 268 | + |
248 | 269 | it("preserves initialMessage after a failed first playback and retries on next trigger", async () => { |
249 | 270 | const provider = new FailFirstPlayTtsProvider("plivo"); |
250 | 271 | const { manager } = await createManagerHarness({}, provider); |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -19,15 +19,15 @@ import type {
|
19 | 19 | } from "./types.js"; |
20 | 20 | |
21 | 21 | export class FakeProvider implements VoiceCallProvider { |
22 | | -readonly name: "plivo" | "twilio"; |
| 22 | +readonly name: "plivo" | "twilio" | "telnyx"; |
23 | 23 | twilioStreamConnectEnabled = true; |
24 | 24 | readonly playTtsCalls: PlayTtsInput[] = []; |
25 | 25 | readonly hangupCalls: HangupCallInput[] = []; |
26 | 26 | readonly startListeningCalls: StartListeningInput[] = []; |
27 | 27 | readonly stopListeningCalls: StopListeningInput[] = []; |
28 | 28 | getCallStatusResult: GetCallStatusResult = { status: "in-progress", isTerminal: false }; |
29 | 29 | |
30 | | -constructor(name: "plivo" | "twilio" = "plivo") { |
| 30 | +constructor(name: "plivo" | "twilio" | "telnyx" = "plivo") { |
31 | 31 | this.name = name; |
32 | 32 | } |
33 | 33 | |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -226,6 +226,36 @@ describe("voice-call outbound helpers", () => {
|
226 | 226 | expect(transitionStateMock).toHaveBeenLastCalledWith(call, "listening"); |
227 | 227 | }); |
228 | 228 | |
| 229 | +it("passes configured voice ids through to Telnyx speak", async () => { |
| 230 | +const call = { callId: "call-1", providerCallId: "provider-1", state: "active" }; |
| 231 | +const playTts = vi.fn(async () => {}); |
| 232 | +const ctx = { |
| 233 | +activeCalls: new Map([["call-1", call]]), |
| 234 | +providerCallIdMap: new Map(), |
| 235 | +provider: { name: "telnyx", playTts }, |
| 236 | +config: { |
| 237 | +tts: { |
| 238 | +provider: "telnyx", |
| 239 | +providers: { |
| 240 | +telnyx: { |
| 241 | +voiceId: "Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc", |
| 242 | +}, |
| 243 | +}, |
| 244 | +}, |
| 245 | +}, |
| 246 | +storePath: "/tmp/voice-call.json", |
| 247 | +}; |
| 248 | + |
| 249 | +await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true }); |
| 250 | + |
| 251 | +expect(playTts).toHaveBeenCalledWith({ |
| 252 | +callId: "call-1", |
| 253 | +providerCallId: "provider-1", |
| 254 | +text: "hello", |
| 255 | +voice: "Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc", |
| 256 | +}); |
| 257 | +}); |
| 258 | + |
229 | 259 | it("sends DTMF through connected provider calls", async () => { |
230 | 260 | const call = { callId: "call-1", providerCallId: "provider-1", state: "active" }; |
231 | 261 | const sendDtmfProvider = vi.fn(async () => {}); |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -213,7 +213,7 @@ export async function speak(
|
213 | 213 | transitionState(call, "speaking"); |
214 | 214 | persistCallRecord(ctx.storePath, call); |
215 | 215 | |
216 | | -const voice = provider.name === "twilio" ? resolvePreferredTtsVoice(ctx.config) : undefined; |
| 216 | +const voice = resolvePreferredTtsVoice(ctx.config); |
217 | 217 | await provider.playTts({ |
218 | 218 | callId, |
219 | 219 | providerCallId, |
@@ -233,6 +233,19 @@ export async function speak(
|
233 | 233 | } |
234 | 234 | } |
235 | 235 | |
| 236 | +function shouldStartListeningAfterInitialMessage(ctx: ConversationContext): boolean { |
| 237 | +if (ctx.provider?.name !== "twilio") { |
| 238 | +return true; |
| 239 | +} |
| 240 | +if (!ctx.config.streaming.enabled) { |
| 241 | +return true; |
| 242 | +} |
| 243 | +const streamAwareProvider = ctx.provider as typeof ctx.provider & { |
| 244 | +isConversationStreamConnectEnabled?: () => boolean; |
| 245 | +}; |
| 246 | +return streamAwareProvider.isConversationStreamConnectEnabled?.() !== true; |
| 247 | +} |
| 248 | + |
236 | 249 | export async function sendDtmf( |
237 | 250 | ctx: SpeakContext, |
238 | 251 | callId: CallId, |
@@ -316,6 +329,17 @@ export async function speakInitialMessage(
|
316 | 329 | await endCall(ctx, call.callId); |
317 | 330 | } |
318 | 331 | }, delaySec * 1000); |
| 332 | +} else if ( |
| 333 | +mode === "conversation" && |
| 334 | +ctx.provider && |
| 335 | +shouldStartListeningAfterInitialMessage(ctx) |
| 336 | +) { |
| 337 | +transitionState(call, "listening"); |
| 338 | +persistCallRecord(ctx.storePath, call); |
| 339 | +await ctx.provider.startListening({ |
| 340 | +callId: call.callId, |
| 341 | + providerCallId, |
| 342 | +}); |
319 | 343 | } |
320 | 344 | } finally { |
321 | 345 | ctx.initialMessageInFlight.delete(call.callId); |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -301,3 +301,40 @@ describe("TelnyxProvider answer control", () => {
|
301 | 301 | expect(release).toHaveBeenCalledTimes(1); |
302 | 302 | }); |
303 | 303 | }); |
| 304 | + |
| 305 | +describe("TelnyxProvider speak control", () => { |
| 306 | +it("passes custom Telnyx voice ids to the speak action", async () => { |
| 307 | +const release = vi.fn(async () => {}); |
| 308 | +apiMocks.fetchWithSsrFGuard.mockResolvedValue({ |
| 309 | +response: new Response(JSON.stringify({ data: {} }), { status: 200 }), |
| 310 | + release, |
| 311 | +}); |
| 312 | +const provider = new TelnyxProvider({ |
| 313 | +apiKey: "KEY123", |
| 314 | +connectionId: "CONN456", |
| 315 | +publicKey: undefined, |
| 316 | +}); |
| 317 | + |
| 318 | +await provider.playTts({ |
| 319 | +callId: "call-1", |
| 320 | +providerCallId: "call-control-1", |
| 321 | +text: "hello", |
| 322 | +voice: "Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc", |
| 323 | +}); |
| 324 | + |
| 325 | +expect(apiMocks.fetchWithSsrFGuard).toHaveBeenCalledWith( |
| 326 | +expect.objectContaining({ |
| 327 | +url: "https://api.telnyx.com/v2/calls/call-control-1/actions/speak", |
| 328 | +auditContext: "voice-call.telnyx.api", |
| 329 | +policy: { allowedHostnames: ["api.telnyx.com"] }, |
| 330 | +init: expect.objectContaining({ |
| 331 | +method: "POST", |
| 332 | +body: expect.stringContaining( |
| 333 | +'"voice":"Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc"', |
| 334 | +), |
| 335 | +}), |
| 336 | +}), |
| 337 | +); |
| 338 | +expect(release).toHaveBeenCalledTimes(1); |
| 339 | +}); |
| 340 | +}); |
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。