fix(voice-call): answer telnyx inbound calls

This commit is contained in:
Peter Steinberger
2026-04-25 03:23:12 +01:00
parent 282c32db7c
commit 31d8fdb525
7 changed files with 122 additions and 2 deletions

View File

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

View File

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

View File

@@ -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":

View File

@@ -1,4 +1,5 @@
import type {
AnswerCallInput,
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
@@ -48,6 +49,12 @@ export interface VoiceCallProvider {
*/
initiateCall(input: InitiateCallInput): Promise<InitiateCallResult>;
/**
* Answer an accepted inbound call when the provider requires an explicit
* answer command after the initial webhook.
*/
answerCall?: (input: AnswerCallInput) => Promise<void>;
/**
* Hang up an active call.
*/

View File

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

View File

@@ -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<void> {
await this.apiRequest(`/calls/${input.providerCallId}/actions/answer`, {
command_id: `openclaw-answer-${input.callId}`,
});
}
/**
* Play TTS audio via Telnyx speak action.
*/

View File

@@ -227,6 +227,11 @@ export type HangupCallInput = {
reason: EndReason;
};
export type AnswerCallInput = {
callId: CallId;
providerCallId: ProviderCallId;
};
export type PlayTtsInput = {
callId: CallId;
providerCallId: ProviderCallId;