mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
fix(voice-call): start listening after telnyx greetings
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user