@openclaw/voice-call 2026.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/README.md +135 -0
- package/index.ts +497 -0
- package/openclaw.plugin.json +601 -0
- package/package.json +16 -0
- package/src/cli.ts +312 -0
- package/src/config.test.ts +204 -0
- package/src/config.ts +502 -0
- package/src/core-bridge.ts +198 -0
- package/src/manager/context.ts +21 -0
- package/src/manager/events.ts +177 -0
- package/src/manager/lookup.ts +33 -0
- package/src/manager/outbound.ts +248 -0
- package/src/manager/state.ts +50 -0
- package/src/manager/store.ts +88 -0
- package/src/manager/timers.ts +86 -0
- package/src/manager/twiml.ts +9 -0
- package/src/manager.test.ts +108 -0
- package/src/manager.ts +888 -0
- package/src/media-stream.test.ts +97 -0
- package/src/media-stream.ts +393 -0
- package/src/providers/base.ts +67 -0
- package/src/providers/index.ts +10 -0
- package/src/providers/mock.ts +168 -0
- package/src/providers/plivo.test.ts +28 -0
- package/src/providers/plivo.ts +504 -0
- package/src/providers/stt-openai-realtime.ts +311 -0
- package/src/providers/telnyx.ts +364 -0
- package/src/providers/tts-openai.ts +264 -0
- package/src/providers/twilio/api.ts +45 -0
- package/src/providers/twilio/webhook.ts +30 -0
- package/src/providers/twilio.test.ts +64 -0
- package/src/providers/twilio.ts +595 -0
- package/src/response-generator.ts +171 -0
- package/src/runtime.ts +217 -0
- package/src/telephony-audio.ts +88 -0
- package/src/telephony-tts.ts +95 -0
- package/src/tunnel.ts +331 -0
- package/src/types.ts +273 -0
- package/src/utils.ts +12 -0
- package/src/voice-mapping.ts +65 -0
- package/src/webhook-security.test.ts +260 -0
- package/src/webhook-security.ts +469 -0
- package/src/webhook.ts +491 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
OpenAIRealtimeSTTProvider,
|
|
5
|
+
RealtimeSTTSession,
|
|
6
|
+
} from "./providers/stt-openai-realtime.js";
|
|
7
|
+
import { MediaStreamHandler } from "./media-stream.js";
|
|
8
|
+
|
|
9
|
+
const createStubSession = (): RealtimeSTTSession => ({
|
|
10
|
+
connect: async () => {},
|
|
11
|
+
sendAudio: () => {},
|
|
12
|
+
waitForTranscript: async () => "",
|
|
13
|
+
onPartial: () => {},
|
|
14
|
+
onTranscript: () => {},
|
|
15
|
+
onSpeechStart: () => {},
|
|
16
|
+
close: () => {},
|
|
17
|
+
isConnected: () => true,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const createStubSttProvider = (): OpenAIRealtimeSTTProvider =>
|
|
21
|
+
({
|
|
22
|
+
createSession: () => createStubSession(),
|
|
23
|
+
}) as unknown as OpenAIRealtimeSTTProvider;
|
|
24
|
+
|
|
25
|
+
const flush = async (): Promise<void> => {
|
|
26
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const waitForAbort = (signal: AbortSignal): Promise<void> =>
|
|
30
|
+
new Promise((resolve) => {
|
|
31
|
+
if (signal.aborted) {
|
|
32
|
+
resolve();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("MediaStreamHandler TTS queue", () => {
|
|
39
|
+
it("serializes TTS playback and resolves in order", async () => {
|
|
40
|
+
const handler = new MediaStreamHandler({
|
|
41
|
+
sttProvider: createStubSttProvider(),
|
|
42
|
+
});
|
|
43
|
+
const started: number[] = [];
|
|
44
|
+
const finished: number[] = [];
|
|
45
|
+
|
|
46
|
+
let resolveFirst!: () => void;
|
|
47
|
+
const firstGate = new Promise<void>((resolve) => {
|
|
48
|
+
resolveFirst = resolve;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const first = handler.queueTts("stream-1", async () => {
|
|
52
|
+
started.push(1);
|
|
53
|
+
await firstGate;
|
|
54
|
+
finished.push(1);
|
|
55
|
+
});
|
|
56
|
+
const second = handler.queueTts("stream-1", async () => {
|
|
57
|
+
started.push(2);
|
|
58
|
+
finished.push(2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await flush();
|
|
62
|
+
expect(started).toEqual([1]);
|
|
63
|
+
|
|
64
|
+
resolveFirst();
|
|
65
|
+
await first;
|
|
66
|
+
await second;
|
|
67
|
+
|
|
68
|
+
expect(started).toEqual([1, 2]);
|
|
69
|
+
expect(finished).toEqual([1, 2]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("cancels active playback and clears queued items", async () => {
|
|
73
|
+
const handler = new MediaStreamHandler({
|
|
74
|
+
sttProvider: createStubSttProvider(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
let queuedRan = false;
|
|
78
|
+
const started: string[] = [];
|
|
79
|
+
|
|
80
|
+
const active = handler.queueTts("stream-1", async (signal) => {
|
|
81
|
+
started.push("active");
|
|
82
|
+
await waitForAbort(signal);
|
|
83
|
+
});
|
|
84
|
+
void handler.queueTts("stream-1", async () => {
|
|
85
|
+
queuedRan = true;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await flush();
|
|
89
|
+
expect(started).toEqual(["active"]);
|
|
90
|
+
|
|
91
|
+
handler.clearTtsQueue("stream-1");
|
|
92
|
+
await active;
|
|
93
|
+
await flush();
|
|
94
|
+
|
|
95
|
+
expect(queuedRan).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Stream Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles bidirectional audio streaming between Twilio and the AI services.
|
|
5
|
+
* - Receives mu-law audio from Twilio via WebSocket
|
|
6
|
+
* - Forwards to OpenAI Realtime STT for transcription
|
|
7
|
+
* - Sends TTS audio back to Twilio
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { IncomingMessage } from "node:http";
|
|
11
|
+
import type { Duplex } from "node:stream";
|
|
12
|
+
|
|
13
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
OpenAIRealtimeSTTProvider,
|
|
17
|
+
RealtimeSTTSession,
|
|
18
|
+
} from "./providers/stt-openai-realtime.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Configuration for the media stream handler.
|
|
22
|
+
*/
|
|
23
|
+
export interface MediaStreamConfig {
|
|
24
|
+
/** STT provider for transcription */
|
|
25
|
+
sttProvider: OpenAIRealtimeSTTProvider;
|
|
26
|
+
/** Callback when transcript is received */
|
|
27
|
+
onTranscript?: (callId: string, transcript: string) => void;
|
|
28
|
+
/** Callback for partial transcripts (streaming UI) */
|
|
29
|
+
onPartialTranscript?: (callId: string, partial: string) => void;
|
|
30
|
+
/** Callback when stream connects */
|
|
31
|
+
onConnect?: (callId: string, streamSid: string) => void;
|
|
32
|
+
/** Callback when speech starts (barge-in) */
|
|
33
|
+
onSpeechStart?: (callId: string) => void;
|
|
34
|
+
/** Callback when stream disconnects */
|
|
35
|
+
onDisconnect?: (callId: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Active media stream session.
|
|
40
|
+
*/
|
|
41
|
+
interface StreamSession {
|
|
42
|
+
callId: string;
|
|
43
|
+
streamSid: string;
|
|
44
|
+
ws: WebSocket;
|
|
45
|
+
sttSession: RealtimeSTTSession;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type TtsQueueEntry = {
|
|
49
|
+
playFn: (signal: AbortSignal) => Promise<void>;
|
|
50
|
+
controller: AbortController;
|
|
51
|
+
resolve: () => void;
|
|
52
|
+
reject: (error: unknown) => void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Manages WebSocket connections for Twilio media streams.
|
|
57
|
+
*/
|
|
58
|
+
export class MediaStreamHandler {
|
|
59
|
+
private wss: WebSocketServer | null = null;
|
|
60
|
+
private sessions = new Map<string, StreamSession>();
|
|
61
|
+
private config: MediaStreamConfig;
|
|
62
|
+
/** TTS playback queues per stream (serialize audio to prevent overlap) */
|
|
63
|
+
private ttsQueues = new Map<string, TtsQueueEntry[]>();
|
|
64
|
+
/** Whether TTS is currently playing per stream */
|
|
65
|
+
private ttsPlaying = new Map<string, boolean>();
|
|
66
|
+
/** Active TTS playback controllers per stream */
|
|
67
|
+
private ttsActiveControllers = new Map<string, AbortController>();
|
|
68
|
+
|
|
69
|
+
constructor(config: MediaStreamConfig) {
|
|
70
|
+
this.config = config;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Handle WebSocket upgrade for media stream connections.
|
|
75
|
+
*/
|
|
76
|
+
handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void {
|
|
77
|
+
if (!this.wss) {
|
|
78
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
79
|
+
this.wss.on("connection", (ws, req) => this.handleConnection(ws, req));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
|
83
|
+
this.wss?.emit("connection", ws, request);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Handle new WebSocket connection from Twilio.
|
|
89
|
+
*/
|
|
90
|
+
private async handleConnection(
|
|
91
|
+
ws: WebSocket,
|
|
92
|
+
_request: IncomingMessage,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
let session: StreamSession | null = null;
|
|
95
|
+
|
|
96
|
+
ws.on("message", async (data: Buffer) => {
|
|
97
|
+
try {
|
|
98
|
+
const message = JSON.parse(data.toString()) as TwilioMediaMessage;
|
|
99
|
+
|
|
100
|
+
switch (message.event) {
|
|
101
|
+
case "connected":
|
|
102
|
+
console.log("[MediaStream] Twilio connected");
|
|
103
|
+
break;
|
|
104
|
+
|
|
105
|
+
case "start":
|
|
106
|
+
session = await this.handleStart(ws, message);
|
|
107
|
+
break;
|
|
108
|
+
|
|
109
|
+
case "media":
|
|
110
|
+
if (session && message.media?.payload) {
|
|
111
|
+
// Forward audio to STT
|
|
112
|
+
const audioBuffer = Buffer.from(message.media.payload, "base64");
|
|
113
|
+
session.sttSession.sendAudio(audioBuffer);
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
|
|
117
|
+
case "stop":
|
|
118
|
+
if (session) {
|
|
119
|
+
this.handleStop(session);
|
|
120
|
+
session = null;
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error("[MediaStream] Error processing message:", error);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
ws.on("close", () => {
|
|
130
|
+
if (session) {
|
|
131
|
+
this.handleStop(session);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
ws.on("error", (error) => {
|
|
136
|
+
console.error("[MediaStream] WebSocket error:", error);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Handle stream start event.
|
|
142
|
+
*/
|
|
143
|
+
private async handleStart(
|
|
144
|
+
ws: WebSocket,
|
|
145
|
+
message: TwilioMediaMessage,
|
|
146
|
+
): Promise<StreamSession> {
|
|
147
|
+
const streamSid = message.streamSid || "";
|
|
148
|
+
const callSid = message.start?.callSid || "";
|
|
149
|
+
|
|
150
|
+
console.log(
|
|
151
|
+
`[MediaStream] Stream started: ${streamSid} (call: ${callSid})`,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Create STT session
|
|
155
|
+
const sttSession = this.config.sttProvider.createSession();
|
|
156
|
+
|
|
157
|
+
// Set up transcript callbacks
|
|
158
|
+
sttSession.onPartial((partial) => {
|
|
159
|
+
this.config.onPartialTranscript?.(callSid, partial);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
sttSession.onTranscript((transcript) => {
|
|
163
|
+
this.config.onTranscript?.(callSid, transcript);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
sttSession.onSpeechStart(() => {
|
|
167
|
+
this.config.onSpeechStart?.(callSid);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const session: StreamSession = {
|
|
171
|
+
callId: callSid,
|
|
172
|
+
streamSid,
|
|
173
|
+
ws,
|
|
174
|
+
sttSession,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
this.sessions.set(streamSid, session);
|
|
178
|
+
|
|
179
|
+
// Notify connection BEFORE STT connect so TTS can work even if STT fails
|
|
180
|
+
this.config.onConnect?.(callSid, streamSid);
|
|
181
|
+
|
|
182
|
+
// Connect to OpenAI STT (non-blocking, log errors but don't fail the call)
|
|
183
|
+
sttSession.connect().catch((err) => {
|
|
184
|
+
console.warn(
|
|
185
|
+
`[MediaStream] STT connection failed (TTS still works):`,
|
|
186
|
+
err.message,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return session;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Handle stream stop event.
|
|
195
|
+
*/
|
|
196
|
+
private handleStop(session: StreamSession): void {
|
|
197
|
+
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
|
|
198
|
+
|
|
199
|
+
this.clearTtsState(session.streamSid);
|
|
200
|
+
session.sttSession.close();
|
|
201
|
+
this.sessions.delete(session.streamSid);
|
|
202
|
+
this.config.onDisconnect?.(session.callId);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get an active session with an open WebSocket, or undefined if unavailable.
|
|
207
|
+
*/
|
|
208
|
+
private getOpenSession(streamSid: string): StreamSession | undefined {
|
|
209
|
+
const session = this.sessions.get(streamSid);
|
|
210
|
+
return session?.ws.readyState === WebSocket.OPEN ? session : undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Send a message to a stream's WebSocket if available.
|
|
215
|
+
*/
|
|
216
|
+
private sendToStream(streamSid: string, message: unknown): void {
|
|
217
|
+
const session = this.getOpenSession(streamSid);
|
|
218
|
+
session?.ws.send(JSON.stringify(message));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Send audio to a specific stream (for TTS playback).
|
|
223
|
+
* Audio should be mu-law encoded at 8kHz mono.
|
|
224
|
+
*/
|
|
225
|
+
sendAudio(streamSid: string, muLawAudio: Buffer): void {
|
|
226
|
+
this.sendToStream(streamSid, {
|
|
227
|
+
event: "media",
|
|
228
|
+
streamSid,
|
|
229
|
+
media: { payload: muLawAudio.toString("base64") },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Send a mark event to track audio playback position.
|
|
235
|
+
*/
|
|
236
|
+
sendMark(streamSid: string, name: string): void {
|
|
237
|
+
this.sendToStream(streamSid, {
|
|
238
|
+
event: "mark",
|
|
239
|
+
streamSid,
|
|
240
|
+
mark: { name },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Clear audio buffer (interrupt playback).
|
|
246
|
+
*/
|
|
247
|
+
clearAudio(streamSid: string): void {
|
|
248
|
+
this.sendToStream(streamSid, { event: "clear", streamSid });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Queue a TTS operation for sequential playback.
|
|
253
|
+
* Only one TTS operation plays at a time per stream to prevent overlap.
|
|
254
|
+
*/
|
|
255
|
+
async queueTts(
|
|
256
|
+
streamSid: string,
|
|
257
|
+
playFn: (signal: AbortSignal) => Promise<void>,
|
|
258
|
+
): Promise<void> {
|
|
259
|
+
const queue = this.getTtsQueue(streamSid);
|
|
260
|
+
let resolveEntry: () => void;
|
|
261
|
+
let rejectEntry: (error: unknown) => void;
|
|
262
|
+
const promise = new Promise<void>((resolve, reject) => {
|
|
263
|
+
resolveEntry = resolve;
|
|
264
|
+
rejectEntry = reject;
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
queue.push({
|
|
268
|
+
playFn,
|
|
269
|
+
controller: new AbortController(),
|
|
270
|
+
resolve: resolveEntry!,
|
|
271
|
+
reject: rejectEntry!,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (!this.ttsPlaying.get(streamSid)) {
|
|
275
|
+
void this.processQueue(streamSid);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return promise;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Clear TTS queue and interrupt current playback (barge-in).
|
|
283
|
+
*/
|
|
284
|
+
clearTtsQueue(streamSid: string): void {
|
|
285
|
+
const queue = this.getTtsQueue(streamSid);
|
|
286
|
+
queue.length = 0;
|
|
287
|
+
this.ttsActiveControllers.get(streamSid)?.abort();
|
|
288
|
+
this.clearAudio(streamSid);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get active session by call ID.
|
|
293
|
+
*/
|
|
294
|
+
getSessionByCallId(callId: string): StreamSession | undefined {
|
|
295
|
+
return [...this.sessions.values()].find(
|
|
296
|
+
(session) => session.callId === callId,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Close all sessions.
|
|
302
|
+
*/
|
|
303
|
+
closeAll(): void {
|
|
304
|
+
for (const session of this.sessions.values()) {
|
|
305
|
+
this.clearTtsState(session.streamSid);
|
|
306
|
+
session.sttSession.close();
|
|
307
|
+
session.ws.close();
|
|
308
|
+
}
|
|
309
|
+
this.sessions.clear();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private getTtsQueue(streamSid: string): TtsQueueEntry[] {
|
|
313
|
+
const existing = this.ttsQueues.get(streamSid);
|
|
314
|
+
if (existing) return existing;
|
|
315
|
+
const queue: TtsQueueEntry[] = [];
|
|
316
|
+
this.ttsQueues.set(streamSid, queue);
|
|
317
|
+
return queue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Process the TTS queue for a stream.
|
|
322
|
+
* Uses iterative approach to avoid stack accumulation from recursion.
|
|
323
|
+
*/
|
|
324
|
+
private async processQueue(streamSid: string): Promise<void> {
|
|
325
|
+
this.ttsPlaying.set(streamSid, true);
|
|
326
|
+
|
|
327
|
+
while (true) {
|
|
328
|
+
const queue = this.ttsQueues.get(streamSid);
|
|
329
|
+
if (!queue || queue.length === 0) {
|
|
330
|
+
this.ttsPlaying.set(streamSid, false);
|
|
331
|
+
this.ttsActiveControllers.delete(streamSid);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const entry = queue.shift()!;
|
|
336
|
+
this.ttsActiveControllers.set(streamSid, entry.controller);
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
await entry.playFn(entry.controller.signal);
|
|
340
|
+
entry.resolve();
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (entry.controller.signal.aborted) {
|
|
343
|
+
entry.resolve();
|
|
344
|
+
} else {
|
|
345
|
+
console.error("[MediaStream] TTS playback error:", error);
|
|
346
|
+
entry.reject(error);
|
|
347
|
+
}
|
|
348
|
+
} finally {
|
|
349
|
+
if (this.ttsActiveControllers.get(streamSid) === entry.controller) {
|
|
350
|
+
this.ttsActiveControllers.delete(streamSid);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private clearTtsState(streamSid: string): void {
|
|
357
|
+
const queue = this.ttsQueues.get(streamSid);
|
|
358
|
+
if (queue) queue.length = 0;
|
|
359
|
+
this.ttsActiveControllers.get(streamSid)?.abort();
|
|
360
|
+
this.ttsActiveControllers.delete(streamSid);
|
|
361
|
+
this.ttsPlaying.delete(streamSid);
|
|
362
|
+
this.ttsQueues.delete(streamSid);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Twilio Media Stream message format.
|
|
368
|
+
*/
|
|
369
|
+
interface TwilioMediaMessage {
|
|
370
|
+
event: "connected" | "start" | "media" | "stop" | "mark" | "clear";
|
|
371
|
+
sequenceNumber?: string;
|
|
372
|
+
streamSid?: string;
|
|
373
|
+
start?: {
|
|
374
|
+
streamSid: string;
|
|
375
|
+
accountSid: string;
|
|
376
|
+
callSid: string;
|
|
377
|
+
tracks: string[];
|
|
378
|
+
mediaFormat: {
|
|
379
|
+
encoding: string;
|
|
380
|
+
sampleRate: number;
|
|
381
|
+
channels: number;
|
|
382
|
+
};
|
|
383
|
+
};
|
|
384
|
+
media?: {
|
|
385
|
+
track?: string;
|
|
386
|
+
chunk?: string;
|
|
387
|
+
timestamp?: string;
|
|
388
|
+
payload?: string;
|
|
389
|
+
};
|
|
390
|
+
mark?: {
|
|
391
|
+
name: string;
|
|
392
|
+
};
|
|
393
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HangupCallInput,
|
|
3
|
+
InitiateCallInput,
|
|
4
|
+
InitiateCallResult,
|
|
5
|
+
PlayTtsInput,
|
|
6
|
+
ProviderName,
|
|
7
|
+
ProviderWebhookParseResult,
|
|
8
|
+
StartListeningInput,
|
|
9
|
+
StopListeningInput,
|
|
10
|
+
WebhookContext,
|
|
11
|
+
WebhookVerificationResult,
|
|
12
|
+
} from "../types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Abstract base interface for voice call providers.
|
|
16
|
+
*
|
|
17
|
+
* Each provider (Telnyx, Twilio, etc.) implements this interface to provide
|
|
18
|
+
* a consistent API for the call manager.
|
|
19
|
+
*
|
|
20
|
+
* Responsibilities:
|
|
21
|
+
* - Webhook verification and event parsing
|
|
22
|
+
* - Outbound call initiation and hangup
|
|
23
|
+
* - Media control (TTS playback, STT listening)
|
|
24
|
+
*/
|
|
25
|
+
export interface VoiceCallProvider {
|
|
26
|
+
/** Provider identifier */
|
|
27
|
+
readonly name: ProviderName;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Verify webhook signature/HMAC before processing.
|
|
31
|
+
* Must be called before parseWebhookEvent.
|
|
32
|
+
*/
|
|
33
|
+
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse provider-specific webhook payload into normalized events.
|
|
37
|
+
* Returns events and optional response to send back to provider.
|
|
38
|
+
*/
|
|
39
|
+
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Initiate an outbound call.
|
|
43
|
+
* @returns Provider call ID and status
|
|
44
|
+
*/
|
|
45
|
+
initiateCall(input: InitiateCallInput): Promise<InitiateCallResult>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Hang up an active call.
|
|
49
|
+
*/
|
|
50
|
+
hangupCall(input: HangupCallInput): Promise<void>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Play TTS audio to the caller.
|
|
54
|
+
* The provider should handle streaming if supported.
|
|
55
|
+
*/
|
|
56
|
+
playTts(input: PlayTtsInput): Promise<void>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Start listening for user speech (activate STT).
|
|
60
|
+
*/
|
|
61
|
+
startListening(input: StartListeningInput): Promise<void>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Stop listening for user speech (deactivate STT).
|
|
65
|
+
*/
|
|
66
|
+
stopListening(input: StopListeningInput): Promise<void>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type { VoiceCallProvider } from "./base.js";
|
|
2
|
+
export { MockProvider } from "./mock.js";
|
|
3
|
+
export {
|
|
4
|
+
OpenAIRealtimeSTTProvider,
|
|
5
|
+
type RealtimeSTTConfig,
|
|
6
|
+
type RealtimeSTTSession,
|
|
7
|
+
} from "./stt-openai-realtime.js";
|
|
8
|
+
export { TelnyxProvider } from "./telnyx.js";
|
|
9
|
+
export { TwilioProvider } from "./twilio.js";
|
|
10
|
+
export { PlivoProvider } from "./plivo.js";
|