fix(voice-call): start listening after telnyx greetings

This commit is contained in:
Peter Steinberger
2026-04-25 03:57:44 +01:00
parent 344ee3782d
commit 455e84f776
6 changed files with 116 additions and 3 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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 () => {});

View File

@@ -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);

View File

@@ -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);
});
});