Skip to content

Commit 5a52fdd

Browse files
committed
fix(voice-call): bind Telnyx realtime streams to issued calls
1 parent 35f693c commit 5a52fdd

9 files changed

Lines changed: 193 additions & 158 deletions

File tree

extensions/voice-call/src/manager/context.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,6 @@ type CallManagerTransientState = {
3131
initialMessageInFlight: Set<CallId>;
3232
};
3333

34-
/**
35-
* Lazily issue a per-call stream session (token + WSS URL) for carriers that
36-
* attach Media Streaming at dial or answer time (e.g. Telnyx). The manager
37-
* calls this just before delegating to the provider's initiate/answer so the
38-
* streaming params can be embedded in the carrier API payload.
39-
*
40-
* Returns `undefined` when realtime is not configured.
41-
*/
4234
export type StreamSessionIssuer = (request: {
4335
providerName: "twilio" | "telnyx";
4436
callId: CallId;
@@ -48,9 +40,7 @@ export type StreamSessionIssuer = (request: {
4840
}) => { token: string; streamUrl: string } | undefined;
4941

5042
type CallManagerHooks = {
51-
/** Optional runtime hook invoked after an event transitions a call into answered state. */
5243
onCallAnswered?: (call: CallRecord) => void;
53-
/** Carrier-side stream session issuer; supplied by runtime when realtime is enabled. */
5444
streamSessionIssuer?: StreamSessionIssuer;
5545
};
5646

extensions/voice-call/src/providers/base.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,6 @@ export interface VoiceCallProvider {
3131
/** Provider identifier */
3232
readonly name: ProviderName;
3333

34-
/**
35-
* Update the public origin (`https://host[:port]`) the gateway is reachable
36-
* at. Providers that build stream URLs at dial/answer time (e.g. Telnyx)
37-
* use it; Twilio derives stream URLs from the request Host header inside
38-
* TwiML rendering and may ignore.
39-
*/
4034
setPublicUrl?(url: string): void;
4135

4236
/**

extensions/voice-call/src/providers/telnyx.ts

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,6 @@ export class TelnyxProvider implements VoiceCallProvider {
210210

211211
case "streaming.started":
212212
case "streaming.stopped":
213-
// Informational webhook acknowledgement. The realtime bridge tracks
214-
// its own lifecycle via the WebSocket; we ack the carrier webhook with
215-
// 200 and skip event emission to avoid duplicate signal at the
216-
// manager. Telnyx surfaces stream errors as `{event:"error"}` JSON
217-
// frames over the WS, not as carrier webhooks, so there is no
218-
// matching `streaming.failed` webhook to handle here — see the
219-
// `error` branch in `TelnyxStreamFrameAdapter`.
220213
return null;
221214

222215
default:
@@ -261,11 +254,6 @@ export class TelnyxProvider implements VoiceCallProvider {
261254
}
262255
}
263256

264-
/**
265-
* Initiate an outbound call via Telnyx API. When `input.streamUrl` is set,
266-
* the dial payload also opens a bidirectional Media Streaming session on
267-
* answer (PCMU 8 kHz), per the Telnyx documented "AI agent" pattern.
268-
*/
269257
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
270258
const body: Record<string, unknown> = {
271259
connection_id: this.connectionId,
@@ -298,11 +286,6 @@ export class TelnyxProvider implements VoiceCallProvider {
298286
);
299287
}
300288

301-
/**
302-
* Answer an inbound Telnyx Call Control leg. When `input.streamUrl` is set,
303-
* the answer action also attaches a bidirectional Media Streaming session
304-
* (PCMU 8 kHz), per the Telnyx canonical "answer-action inline" pattern.
305-
*/
306289
async answerCall(input: AnswerCallInput): Promise<void> {
307290
const body: Record<string, unknown> = {
308291
command_id: `openclaw-answer-${input.callId}`,
@@ -378,12 +361,6 @@ export class TelnyxProvider implements VoiceCallProvider {
378361
}
379362
}
380363

381-
/**
382-
* Build the streaming-related fields for a Telnyx dial or answer-action
383-
* payload. PCMU 8 kHz mono only; bidirectional via RTP; target legs `"self"`
384-
* so the bot receives both inbound and outbound audio without the routing
385-
* gotcha that drops the call leg when `"opposite"` is configured.
386-
*/
387364
function buildTelnyxStreamingFields(
388365
streamUrl: string,
389366
streamAuthToken: string | undefined,
@@ -400,10 +377,6 @@ function buildTelnyxStreamingFields(
400377
};
401378
}
402379

403-
// -----------------------------------------------------------------------------
404-
// Telnyx-specific types
405-
// -----------------------------------------------------------------------------
406-
407380
interface TelnyxEvent {
408381
id?: string;
409382
event_type: string;

extensions/voice-call/src/runtime.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -464,11 +464,6 @@ export async function createVoiceCallRuntime(params: {
464464
webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
465465
}
466466

467-
// Once the realtime handler has its public URL, expose its session issuer
468-
// to the manager so carriers that attach Media Streaming at dial / answer
469-
// time (e.g. Telnyx) can embed the streaming params in their carrier API
470-
// payloads. Twilio learns the stream URL from TwiML so this is a no-op
471-
// for the Twilio path.
472467
const realtimeHandler = webhookServer.getRealtimeHandler();
473468
if (realtimeHandler) {
474469
manager.streamSessionIssuer = (request) => realtimeHandler.issueStreamSession(request);

extensions/voice-call/src/webhook/realtime-audio-pacer.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,23 +25,12 @@ type RealtimeAudioQueueItem =
2525

2626
export type RealtimeAudioSend = (message: string) => boolean;
2727

28-
/**
29-
* Outbound frame serializers supplied by the active {@link StreamFrameAdapter}.
30-
* Keeps the pacer carrier-agnostic; Twilio supplies streamSid in the resulting
31-
* envelope while Telnyx omits it.
32-
*/
3328
export interface RealtimeAudioSerializer {
3429
media(payloadBase64: string): string;
3530
clear(): string;
3631
mark(name: string): string;
3732
}
3833

39-
/**
40-
* Carrier-agnostic media-streaming pacer. Buffers μ-law audio into
41-
* 20ms / 160-byte frames, serializes via the provided serializer, and
42-
* dispatches at the telephony cadence. Marks are queued in order so the
43-
* carrier observes them after the audio they tag has been delivered.
44-
*/
4534
export class RealtimeAudioPacer {
4635
private queue: RealtimeAudioQueueItem[] = [];
4736
private timer: ReturnType<typeof setTimeout> | null = null;

extensions/voice-call/src/webhook/realtime-handler.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ function makeHandler(
8787
config,
8888
{
8989
processEvent: vi.fn(),
90+
getCall: vi.fn(),
9091
getCallByProviderCallId: vi.fn(),
9192
...deps?.manager,
9293
} as unknown as CallManager,
@@ -128,6 +129,21 @@ const startRealtimeServer = async (
128129
});
129130
};
130131

132+
const startStreamSessionServer = async (
133+
handler: RealtimeCallHandler,
134+
streamUrl: string,
135+
): Promise<{
136+
url: string;
137+
close: () => Promise<void>;
138+
}> => {
139+
return await startUpgradeWsServer({
140+
urlPath: new URL(streamUrl).pathname,
141+
onUpgrade: (request, socket, head) => {
142+
handler.handleWebSocketUpgrade(request, socket, head);
143+
},
144+
});
145+
};
146+
131147
async function waitForRealtimeTest(
132148
callback: () => void | Promise<void>,
133149
options: { timeout?: number; interval?: number } = {},
@@ -252,6 +268,130 @@ describe("RealtimeCallHandler path routing", () => {
252268
}
253269
});
254270

271+
it("joins Telnyx realtime streams to the token-bound call", async () => {
272+
const processEvent = vi.fn();
273+
const getCall = vi.fn(
274+
(): CallRecord => ({
275+
callId: "call-1",
276+
providerCallId: "v3:call-1",
277+
provider: "telnyx",
278+
direction: "inbound",
279+
state: "answered",
280+
from: "+15550001234",
281+
to: "+15550009999",
282+
startedAt: Date.now(),
283+
transcript: [],
284+
processedEventIds: [],
285+
metadata: { initialMessage: "hello" },
286+
}),
287+
);
288+
const createBridge = vi.fn(() => makeBridge());
289+
const handler = makeHandler(undefined, {
290+
manager: {
291+
processEvent,
292+
getCall,
293+
},
294+
provider: {
295+
name: "telnyx",
296+
},
297+
realtimeProvider: makeRealtimeProvider(createBridge),
298+
});
299+
handler.setPublicUrl("https://public.example/voice/webhook");
300+
const session = handler.issueStreamSession({
301+
providerName: "telnyx",
302+
callId: "call-1",
303+
from: "+15550001234",
304+
to: "+15550009999",
305+
direction: "inbound",
306+
});
307+
const server = await startStreamSessionServer(handler, session.streamUrl);
308+
309+
try {
310+
const ws = await connectWs(server.url);
311+
try {
312+
ws.send(
313+
JSON.stringify({
314+
event: "start",
315+
stream_id: "stream-1",
316+
start: { call_control_id: "v3:call-1" },
317+
}),
318+
);
319+
await waitForRealtimeTest(() => {
320+
expect(createBridge).toHaveBeenCalled();
321+
});
322+
323+
const eventTypes = processEvent.mock.calls.map(
324+
([event]) => (event as NormalizedEvent).type,
325+
);
326+
expect(eventTypes).toEqual(["call.answered"]);
327+
expect((processEvent.mock.calls[0]?.[0] as NormalizedEvent | undefined)?.callId).toBe(
328+
"call-1",
329+
);
330+
} finally {
331+
if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
332+
ws.close();
333+
}
334+
}
335+
} finally {
336+
await server.close();
337+
}
338+
});
339+
340+
it("rejects Telnyx stream starts that do not match the token-bound call", async () => {
341+
const processEvent = vi.fn();
342+
const getCall = vi.fn(
343+
(): CallRecord => ({
344+
callId: "call-1",
345+
providerCallId: "v3:call-1",
346+
provider: "telnyx",
347+
direction: "inbound",
348+
state: "answered",
349+
from: "+15550001234",
350+
to: "+15550009999",
351+
startedAt: Date.now(),
352+
transcript: [],
353+
processedEventIds: [],
354+
metadata: {},
355+
}),
356+
);
357+
const createBridge = vi.fn(() => makeBridge());
358+
const handler = makeHandler(undefined, {
359+
manager: {
360+
processEvent,
361+
getCall,
362+
},
363+
provider: {
364+
name: "telnyx",
365+
},
366+
realtimeProvider: makeRealtimeProvider(createBridge),
367+
});
368+
handler.setPublicUrl("https://public.example/voice/webhook");
369+
const session = handler.issueStreamSession({
370+
providerName: "telnyx",
371+
callId: "call-1",
372+
direction: "inbound",
373+
});
374+
const server = await startStreamSessionServer(handler, session.streamUrl);
375+
376+
try {
377+
const ws = await connectWs(server.url);
378+
ws.send(
379+
JSON.stringify({
380+
event: "start",
381+
stream_id: "stream-1",
382+
start: { call_control_id: "v3:other" },
383+
}),
384+
);
385+
const close = await waitForClose(ws);
386+
387+
expect(close.code).toBe(1008);
388+
expect(createBridge).not.toHaveBeenCalled();
389+
expect(processEvent).not.toHaveBeenCalled();
390+
} finally {
391+
await server.close();
392+
}
393+
});
394+
255395
it("does not emit an outbound realtime greeting without an initial message", async () => {
256396
let callbacks:
257397
| {

0 commit comments

Comments
 (0)