mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
fix(voice-call): answer telnyx inbound calls
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -227,6 +227,11 @@ export type HangupCallInput = {
|
||||
reason: EndReason;
|
||||
};
|
||||
|
||||
export type AnswerCallInput = {
|
||||
callId: CallId;
|
||||
providerCallId: ProviderCallId;
|
||||
};
|
||||
|
||||
export type PlayTtsInput = {
|
||||
callId: CallId;
|
||||
providerCallId: ProviderCallId;
|
||||
|
||||
Reference in New Issue
Block a user