@clawdbot/voice-call 0.1.0
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 +20 -0
- package/README.md +107 -0
- package/index.ts +477 -0
- package/package.json +14 -0
- package/src/cli.ts +297 -0
- package/src/config.ts +355 -0
- package/src/core-bridge.ts +190 -0
- package/src/manager.ts +846 -0
- package/src/media-stream.ts +279 -0
- package/src/providers/base.ts +67 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/mock.ts +168 -0
- package/src/providers/stt-openai-realtime.ts +303 -0
- package/src/providers/telnyx.ts +364 -0
- package/src/providers/tts-openai.ts +264 -0
- package/src/providers/twilio.ts +537 -0
- package/src/response-generator.ts +171 -0
- package/src/runtime.ts +194 -0
- package/src/tunnel.ts +330 -0
- package/src/types.ts +272 -0
- package/src/utils.ts +12 -0
- package/src/voice-mapping.ts +65 -0
- package/src/webhook-security.ts +197 -0
- package/src/webhook.ts +480 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Realtime STT Provider
|
|
3
|
+
*
|
|
4
|
+
* Uses the OpenAI Realtime API for streaming transcription with:
|
|
5
|
+
* - Direct mu-law audio support (no conversion needed)
|
|
6
|
+
* - Built-in server-side VAD for turn detection
|
|
7
|
+
* - Low-latency streaming transcription
|
|
8
|
+
* - Partial transcript callbacks for real-time UI updates
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import WebSocket from "ws";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Configuration for OpenAI Realtime STT.
|
|
15
|
+
*/
|
|
16
|
+
export interface RealtimeSTTConfig {
|
|
17
|
+
/** OpenAI API key */
|
|
18
|
+
apiKey: string;
|
|
19
|
+
/** Model to use (default: gpt-4o-transcribe) */
|
|
20
|
+
model?: string;
|
|
21
|
+
/** Silence duration in ms before considering speech ended (default: 800) */
|
|
22
|
+
silenceDurationMs?: number;
|
|
23
|
+
/** VAD threshold 0-1 (default: 0.5) */
|
|
24
|
+
vadThreshold?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Session for streaming audio and receiving transcripts.
|
|
29
|
+
*/
|
|
30
|
+
export interface RealtimeSTTSession {
|
|
31
|
+
/** Connect to the transcription service */
|
|
32
|
+
connect(): Promise<void>;
|
|
33
|
+
/** Send mu-law audio data (8kHz mono) */
|
|
34
|
+
sendAudio(audio: Buffer): void;
|
|
35
|
+
/** Wait for next complete transcript (after VAD detects end of speech) */
|
|
36
|
+
waitForTranscript(timeoutMs?: number): Promise<string>;
|
|
37
|
+
/** Set callback for partial transcripts (streaming) */
|
|
38
|
+
onPartial(callback: (partial: string) => void): void;
|
|
39
|
+
/** Set callback for final transcripts */
|
|
40
|
+
onTranscript(callback: (transcript: string) => void): void;
|
|
41
|
+
/** Close the session */
|
|
42
|
+
close(): void;
|
|
43
|
+
/** Check if session is connected */
|
|
44
|
+
isConnected(): boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Provider factory for OpenAI Realtime STT sessions.
|
|
49
|
+
*/
|
|
50
|
+
export class OpenAIRealtimeSTTProvider {
|
|
51
|
+
readonly name = "openai-realtime";
|
|
52
|
+
private apiKey: string;
|
|
53
|
+
private model: string;
|
|
54
|
+
private silenceDurationMs: number;
|
|
55
|
+
private vadThreshold: number;
|
|
56
|
+
|
|
57
|
+
constructor(config: RealtimeSTTConfig) {
|
|
58
|
+
if (!config.apiKey) {
|
|
59
|
+
throw new Error("OpenAI API key required for Realtime STT");
|
|
60
|
+
}
|
|
61
|
+
this.apiKey = config.apiKey;
|
|
62
|
+
this.model = config.model || "gpt-4o-transcribe";
|
|
63
|
+
this.silenceDurationMs = config.silenceDurationMs || 800;
|
|
64
|
+
this.vadThreshold = config.vadThreshold || 0.5;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create a new realtime transcription session.
|
|
69
|
+
*/
|
|
70
|
+
createSession(): RealtimeSTTSession {
|
|
71
|
+
return new OpenAIRealtimeSTTSession(
|
|
72
|
+
this.apiKey,
|
|
73
|
+
this.model,
|
|
74
|
+
this.silenceDurationMs,
|
|
75
|
+
this.vadThreshold,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* WebSocket-based session for real-time speech-to-text.
|
|
82
|
+
*/
|
|
83
|
+
class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
|
84
|
+
private static readonly MAX_RECONNECT_ATTEMPTS = 5;
|
|
85
|
+
private static readonly RECONNECT_DELAY_MS = 1000;
|
|
86
|
+
|
|
87
|
+
private ws: WebSocket | null = null;
|
|
88
|
+
private connected = false;
|
|
89
|
+
private closed = false;
|
|
90
|
+
private reconnectAttempts = 0;
|
|
91
|
+
private pendingTranscript = "";
|
|
92
|
+
private onTranscriptCallback: ((transcript: string) => void) | null = null;
|
|
93
|
+
private onPartialCallback: ((partial: string) => void) | null = null;
|
|
94
|
+
|
|
95
|
+
constructor(
|
|
96
|
+
private readonly apiKey: string,
|
|
97
|
+
private readonly model: string,
|
|
98
|
+
private readonly silenceDurationMs: number,
|
|
99
|
+
private readonly vadThreshold: number,
|
|
100
|
+
) {}
|
|
101
|
+
|
|
102
|
+
async connect(): Promise<void> {
|
|
103
|
+
this.closed = false;
|
|
104
|
+
this.reconnectAttempts = 0;
|
|
105
|
+
return this.doConnect();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async doConnect(): Promise<void> {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const url = "wss://api.openai.com/v1/realtime?intent=transcription";
|
|
111
|
+
|
|
112
|
+
this.ws = new WebSocket(url, {
|
|
113
|
+
headers: {
|
|
114
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
115
|
+
"OpenAI-Beta": "realtime=v1",
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
this.ws.on("open", () => {
|
|
120
|
+
console.log("[RealtimeSTT] WebSocket connected");
|
|
121
|
+
this.connected = true;
|
|
122
|
+
this.reconnectAttempts = 0;
|
|
123
|
+
|
|
124
|
+
// Configure the transcription session
|
|
125
|
+
this.sendEvent({
|
|
126
|
+
type: "transcription_session.update",
|
|
127
|
+
session: {
|
|
128
|
+
input_audio_format: "g711_ulaw",
|
|
129
|
+
input_audio_transcription: {
|
|
130
|
+
model: this.model,
|
|
131
|
+
},
|
|
132
|
+
turn_detection: {
|
|
133
|
+
type: "server_vad",
|
|
134
|
+
threshold: this.vadThreshold,
|
|
135
|
+
prefix_padding_ms: 300,
|
|
136
|
+
silence_duration_ms: this.silenceDurationMs,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
resolve();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
this.ws.on("message", (data: Buffer) => {
|
|
145
|
+
try {
|
|
146
|
+
const event = JSON.parse(data.toString());
|
|
147
|
+
this.handleEvent(event);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
console.error("[RealtimeSTT] Failed to parse event:", e);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
this.ws.on("error", (error) => {
|
|
154
|
+
console.error("[RealtimeSTT] WebSocket error:", error);
|
|
155
|
+
if (!this.connected) reject(error);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
this.ws.on("close", (code, reason) => {
|
|
159
|
+
console.log(
|
|
160
|
+
`[RealtimeSTT] WebSocket closed (code: ${code}, reason: ${reason?.toString() || "none"})`,
|
|
161
|
+
);
|
|
162
|
+
this.connected = false;
|
|
163
|
+
|
|
164
|
+
// Attempt reconnection if not intentionally closed
|
|
165
|
+
if (!this.closed) {
|
|
166
|
+
void this.attemptReconnect();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
if (!this.connected) {
|
|
172
|
+
reject(new Error("Realtime STT connection timeout"));
|
|
173
|
+
}
|
|
174
|
+
}, 10000);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async attemptReconnect(): Promise<void> {
|
|
179
|
+
if (this.closed) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (
|
|
184
|
+
this.reconnectAttempts >= OpenAIRealtimeSTTSession.MAX_RECONNECT_ATTEMPTS
|
|
185
|
+
) {
|
|
186
|
+
console.error(
|
|
187
|
+
`[RealtimeSTT] Max reconnect attempts (${OpenAIRealtimeSTTSession.MAX_RECONNECT_ATTEMPTS}) reached`,
|
|
188
|
+
);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.reconnectAttempts++;
|
|
193
|
+
const delay =
|
|
194
|
+
OpenAIRealtimeSTTSession.RECONNECT_DELAY_MS *
|
|
195
|
+
2 ** (this.reconnectAttempts - 1);
|
|
196
|
+
console.log(
|
|
197
|
+
`[RealtimeSTT] Reconnecting ${this.reconnectAttempts}/${OpenAIRealtimeSTTSession.MAX_RECONNECT_ATTEMPTS} in ${delay}ms...`,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
201
|
+
|
|
202
|
+
if (this.closed) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
await this.doConnect();
|
|
208
|
+
console.log("[RealtimeSTT] Reconnected successfully");
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error("[RealtimeSTT] Reconnect failed:", error);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private handleEvent(event: {
|
|
215
|
+
type: string;
|
|
216
|
+
delta?: string;
|
|
217
|
+
transcript?: string;
|
|
218
|
+
error?: unknown;
|
|
219
|
+
}): void {
|
|
220
|
+
switch (event.type) {
|
|
221
|
+
case "transcription_session.created":
|
|
222
|
+
case "transcription_session.updated":
|
|
223
|
+
case "input_audio_buffer.speech_stopped":
|
|
224
|
+
case "input_audio_buffer.committed":
|
|
225
|
+
console.log(`[RealtimeSTT] ${event.type}`);
|
|
226
|
+
break;
|
|
227
|
+
|
|
228
|
+
case "conversation.item.input_audio_transcription.delta":
|
|
229
|
+
if (event.delta) {
|
|
230
|
+
this.pendingTranscript += event.delta;
|
|
231
|
+
this.onPartialCallback?.(this.pendingTranscript);
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
|
|
235
|
+
case "conversation.item.input_audio_transcription.completed":
|
|
236
|
+
if (event.transcript) {
|
|
237
|
+
console.log(`[RealtimeSTT] Transcript: ${event.transcript}`);
|
|
238
|
+
this.onTranscriptCallback?.(event.transcript);
|
|
239
|
+
}
|
|
240
|
+
this.pendingTranscript = "";
|
|
241
|
+
break;
|
|
242
|
+
|
|
243
|
+
case "input_audio_buffer.speech_started":
|
|
244
|
+
console.log("[RealtimeSTT] Speech started");
|
|
245
|
+
this.pendingTranscript = "";
|
|
246
|
+
break;
|
|
247
|
+
|
|
248
|
+
case "error":
|
|
249
|
+
console.error("[RealtimeSTT] Error:", event.error);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private sendEvent(event: unknown): void {
|
|
255
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
256
|
+
this.ws.send(JSON.stringify(event));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
sendAudio(muLawData: Buffer): void {
|
|
261
|
+
if (!this.connected) return;
|
|
262
|
+
this.sendEvent({
|
|
263
|
+
type: "input_audio_buffer.append",
|
|
264
|
+
audio: muLawData.toString("base64"),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
onPartial(callback: (partial: string) => void): void {
|
|
269
|
+
this.onPartialCallback = callback;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
onTranscript(callback: (transcript: string) => void): void {
|
|
273
|
+
this.onTranscriptCallback = callback;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async waitForTranscript(timeoutMs = 30000): Promise<string> {
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
const timeout = setTimeout(() => {
|
|
279
|
+
this.onTranscriptCallback = null;
|
|
280
|
+
reject(new Error("Transcript timeout"));
|
|
281
|
+
}, timeoutMs);
|
|
282
|
+
|
|
283
|
+
this.onTranscriptCallback = (transcript) => {
|
|
284
|
+
clearTimeout(timeout);
|
|
285
|
+
this.onTranscriptCallback = null;
|
|
286
|
+
resolve(transcript);
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
close(): void {
|
|
292
|
+
this.closed = true;
|
|
293
|
+
if (this.ws) {
|
|
294
|
+
this.ws.close();
|
|
295
|
+
this.ws = null;
|
|
296
|
+
}
|
|
297
|
+
this.connected = false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
isConnected(): boolean {
|
|
301
|
+
return this.connected;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { TelnyxConfig } from "../config.js";
|
|
4
|
+
import type {
|
|
5
|
+
EndReason,
|
|
6
|
+
HangupCallInput,
|
|
7
|
+
InitiateCallInput,
|
|
8
|
+
InitiateCallResult,
|
|
9
|
+
NormalizedEvent,
|
|
10
|
+
PlayTtsInput,
|
|
11
|
+
ProviderWebhookParseResult,
|
|
12
|
+
StartListeningInput,
|
|
13
|
+
StopListeningInput,
|
|
14
|
+
WebhookContext,
|
|
15
|
+
WebhookVerificationResult,
|
|
16
|
+
} from "../types.js";
|
|
17
|
+
import type { VoiceCallProvider } from "./base.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Telnyx Voice API provider implementation.
|
|
21
|
+
*
|
|
22
|
+
* Uses Telnyx Call Control API v2 for managing calls.
|
|
23
|
+
* @see https://developers.telnyx.com/docs/api/v2/call-control
|
|
24
|
+
*/
|
|
25
|
+
export class TelnyxProvider implements VoiceCallProvider {
|
|
26
|
+
readonly name = "telnyx" as const;
|
|
27
|
+
|
|
28
|
+
private readonly apiKey: string;
|
|
29
|
+
private readonly connectionId: string;
|
|
30
|
+
private readonly publicKey: string | undefined;
|
|
31
|
+
private readonly baseUrl = "https://api.telnyx.com/v2";
|
|
32
|
+
|
|
33
|
+
constructor(config: TelnyxConfig) {
|
|
34
|
+
if (!config.apiKey) {
|
|
35
|
+
throw new Error("Telnyx API key is required");
|
|
36
|
+
}
|
|
37
|
+
if (!config.connectionId) {
|
|
38
|
+
throw new Error("Telnyx connection ID is required");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.apiKey = config.apiKey;
|
|
42
|
+
this.connectionId = config.connectionId;
|
|
43
|
+
this.publicKey = config.publicKey;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Make an authenticated request to the Telnyx API.
|
|
48
|
+
*/
|
|
49
|
+
private async apiRequest<T = unknown>(
|
|
50
|
+
endpoint: string,
|
|
51
|
+
body: Record<string, unknown>,
|
|
52
|
+
options?: { allowNotFound?: boolean },
|
|
53
|
+
): Promise<T> {
|
|
54
|
+
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: {
|
|
57
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
if (options?.allowNotFound && response.status === 404) {
|
|
65
|
+
return undefined as T;
|
|
66
|
+
}
|
|
67
|
+
const errorText = await response.text();
|
|
68
|
+
throw new Error(`Telnyx API error: ${response.status} ${errorText}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const text = await response.text();
|
|
72
|
+
return text ? (JSON.parse(text) as T) : (undefined as T);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Verify Telnyx webhook signature using Ed25519.
|
|
77
|
+
*/
|
|
78
|
+
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
|
79
|
+
if (!this.publicKey) {
|
|
80
|
+
// No public key configured, skip verification (not recommended for production)
|
|
81
|
+
return { ok: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const signature = ctx.headers["telnyx-signature-ed25519"];
|
|
85
|
+
const timestamp = ctx.headers["telnyx-timestamp"];
|
|
86
|
+
|
|
87
|
+
if (!signature || !timestamp) {
|
|
88
|
+
return { ok: false, reason: "Missing signature or timestamp header" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const signatureStr = Array.isArray(signature) ? signature[0] : signature;
|
|
92
|
+
const timestampStr = Array.isArray(timestamp) ? timestamp[0] : timestamp;
|
|
93
|
+
|
|
94
|
+
if (!signatureStr || !timestampStr) {
|
|
95
|
+
return { ok: false, reason: "Empty signature or timestamp" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const signedPayload = `${timestampStr}|${ctx.rawBody}`;
|
|
100
|
+
const signatureBuffer = Buffer.from(signatureStr, "base64");
|
|
101
|
+
const publicKeyBuffer = Buffer.from(this.publicKey, "base64");
|
|
102
|
+
|
|
103
|
+
const isValid = crypto.verify(
|
|
104
|
+
null, // Ed25519 doesn't use a digest
|
|
105
|
+
Buffer.from(signedPayload),
|
|
106
|
+
{
|
|
107
|
+
key: publicKeyBuffer,
|
|
108
|
+
format: "der",
|
|
109
|
+
type: "spki",
|
|
110
|
+
},
|
|
111
|
+
signatureBuffer,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (!isValid) {
|
|
115
|
+
return { ok: false, reason: "Invalid signature" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check timestamp is within 5 minutes
|
|
119
|
+
const eventTime = parseInt(timestampStr, 10) * 1000;
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
if (Math.abs(now - eventTime) > 5 * 60 * 1000) {
|
|
122
|
+
return { ok: false, reason: "Timestamp too old" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { ok: true };
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse Telnyx webhook event into normalized format.
|
|
136
|
+
*/
|
|
137
|
+
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
|
|
138
|
+
try {
|
|
139
|
+
const payload = JSON.parse(ctx.rawBody);
|
|
140
|
+
const data = payload.data;
|
|
141
|
+
|
|
142
|
+
if (!data || !data.event_type) {
|
|
143
|
+
return { events: [], statusCode: 200 };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const event = this.normalizeEvent(data);
|
|
147
|
+
return {
|
|
148
|
+
events: event ? [event] : [],
|
|
149
|
+
statusCode: 200,
|
|
150
|
+
};
|
|
151
|
+
} catch {
|
|
152
|
+
return { events: [], statusCode: 400 };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Convert Telnyx event to normalized event format.
|
|
158
|
+
*/
|
|
159
|
+
private normalizeEvent(data: TelnyxEvent): NormalizedEvent | null {
|
|
160
|
+
// Decode client_state from Base64 (we encode it in initiateCall)
|
|
161
|
+
let callId = "";
|
|
162
|
+
if (data.payload?.client_state) {
|
|
163
|
+
try {
|
|
164
|
+
callId = Buffer.from(data.payload.client_state, "base64").toString(
|
|
165
|
+
"utf8",
|
|
166
|
+
);
|
|
167
|
+
} catch {
|
|
168
|
+
// Fallback if not valid Base64
|
|
169
|
+
callId = data.payload.client_state;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!callId) {
|
|
173
|
+
callId = data.payload?.call_control_id || "";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const baseEvent = {
|
|
177
|
+
id: data.id || crypto.randomUUID(),
|
|
178
|
+
callId,
|
|
179
|
+
providerCallId: data.payload?.call_control_id,
|
|
180
|
+
timestamp: Date.now(),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
switch (data.event_type) {
|
|
184
|
+
case "call.initiated":
|
|
185
|
+
return { ...baseEvent, type: "call.initiated" };
|
|
186
|
+
|
|
187
|
+
case "call.ringing":
|
|
188
|
+
return { ...baseEvent, type: "call.ringing" };
|
|
189
|
+
|
|
190
|
+
case "call.answered":
|
|
191
|
+
return { ...baseEvent, type: "call.answered" };
|
|
192
|
+
|
|
193
|
+
case "call.bridged":
|
|
194
|
+
return { ...baseEvent, type: "call.active" };
|
|
195
|
+
|
|
196
|
+
case "call.speak.started":
|
|
197
|
+
return {
|
|
198
|
+
...baseEvent,
|
|
199
|
+
type: "call.speaking",
|
|
200
|
+
text: data.payload?.text || "",
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
case "call.transcription":
|
|
204
|
+
return {
|
|
205
|
+
...baseEvent,
|
|
206
|
+
type: "call.speech",
|
|
207
|
+
transcript: data.payload?.transcription || "",
|
|
208
|
+
isFinal: data.payload?.is_final ?? true,
|
|
209
|
+
confidence: data.payload?.confidence,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
case "call.hangup":
|
|
213
|
+
return {
|
|
214
|
+
...baseEvent,
|
|
215
|
+
type: "call.ended",
|
|
216
|
+
reason: this.mapHangupCause(data.payload?.hangup_cause),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
case "call.dtmf.received":
|
|
220
|
+
return {
|
|
221
|
+
...baseEvent,
|
|
222
|
+
type: "call.dtmf",
|
|
223
|
+
digits: data.payload?.digit || "",
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
default:
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Map Telnyx hangup cause to normalized end reason.
|
|
233
|
+
* @see https://developers.telnyx.com/docs/api/v2/call-control/Call-Commands#hangup-causes
|
|
234
|
+
*/
|
|
235
|
+
private mapHangupCause(cause?: string): EndReason {
|
|
236
|
+
switch (cause) {
|
|
237
|
+
case "normal_clearing":
|
|
238
|
+
case "normal_unspecified":
|
|
239
|
+
return "completed";
|
|
240
|
+
case "originator_cancel":
|
|
241
|
+
return "hangup-bot";
|
|
242
|
+
case "call_rejected":
|
|
243
|
+
case "user_busy":
|
|
244
|
+
return "busy";
|
|
245
|
+
case "no_answer":
|
|
246
|
+
case "no_user_response":
|
|
247
|
+
return "no-answer";
|
|
248
|
+
case "destination_out_of_order":
|
|
249
|
+
case "network_out_of_order":
|
|
250
|
+
case "service_unavailable":
|
|
251
|
+
case "recovery_on_timer_expire":
|
|
252
|
+
return "failed";
|
|
253
|
+
case "machine_detected":
|
|
254
|
+
case "fax_detected":
|
|
255
|
+
return "voicemail";
|
|
256
|
+
case "user_hangup":
|
|
257
|
+
case "subscriber_absent":
|
|
258
|
+
return "hangup-user";
|
|
259
|
+
default:
|
|
260
|
+
// Unknown cause - log it for debugging and return completed
|
|
261
|
+
if (cause) {
|
|
262
|
+
console.warn(`[telnyx] Unknown hangup cause: ${cause}`);
|
|
263
|
+
}
|
|
264
|
+
return "completed";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Initiate an outbound call via Telnyx API.
|
|
270
|
+
*/
|
|
271
|
+
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
|
|
272
|
+
const result = await this.apiRequest<TelnyxCallResponse>("/calls", {
|
|
273
|
+
connection_id: this.connectionId,
|
|
274
|
+
to: input.to,
|
|
275
|
+
from: input.from,
|
|
276
|
+
webhook_url: input.webhookUrl,
|
|
277
|
+
webhook_url_method: "POST",
|
|
278
|
+
client_state: Buffer.from(input.callId).toString("base64"),
|
|
279
|
+
timeout_secs: 30,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
providerCallId: result.data.call_control_id,
|
|
284
|
+
status: "initiated",
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Hang up a call via Telnyx API.
|
|
290
|
+
*/
|
|
291
|
+
async hangupCall(input: HangupCallInput): Promise<void> {
|
|
292
|
+
await this.apiRequest(
|
|
293
|
+
`/calls/${input.providerCallId}/actions/hangup`,
|
|
294
|
+
{ command_id: crypto.randomUUID() },
|
|
295
|
+
{ allowNotFound: true },
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Play TTS audio via Telnyx speak action.
|
|
301
|
+
*/
|
|
302
|
+
async playTts(input: PlayTtsInput): Promise<void> {
|
|
303
|
+
await this.apiRequest(`/calls/${input.providerCallId}/actions/speak`, {
|
|
304
|
+
command_id: crypto.randomUUID(),
|
|
305
|
+
payload: input.text,
|
|
306
|
+
voice: input.voice || "female",
|
|
307
|
+
language: input.locale || "en-US",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Start transcription (STT) via Telnyx.
|
|
313
|
+
*/
|
|
314
|
+
async startListening(input: StartListeningInput): Promise<void> {
|
|
315
|
+
await this.apiRequest(
|
|
316
|
+
`/calls/${input.providerCallId}/actions/transcription_start`,
|
|
317
|
+
{
|
|
318
|
+
command_id: crypto.randomUUID(),
|
|
319
|
+
language: input.language || "en",
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Stop transcription via Telnyx.
|
|
326
|
+
*/
|
|
327
|
+
async stopListening(input: StopListeningInput): Promise<void> {
|
|
328
|
+
await this.apiRequest(
|
|
329
|
+
`/calls/${input.providerCallId}/actions/transcription_stop`,
|
|
330
|
+
{ command_id: crypto.randomUUID() },
|
|
331
|
+
{ allowNotFound: true },
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// -----------------------------------------------------------------------------
|
|
337
|
+
// Telnyx-specific types
|
|
338
|
+
// -----------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
interface TelnyxEvent {
|
|
341
|
+
id?: string;
|
|
342
|
+
event_type: string;
|
|
343
|
+
payload?: {
|
|
344
|
+
call_control_id?: string;
|
|
345
|
+
client_state?: string;
|
|
346
|
+
text?: string;
|
|
347
|
+
transcription?: string;
|
|
348
|
+
is_final?: boolean;
|
|
349
|
+
confidence?: number;
|
|
350
|
+
hangup_cause?: string;
|
|
351
|
+
digit?: string;
|
|
352
|
+
[key: string]: unknown;
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
interface TelnyxCallResponse {
|
|
357
|
+
data: {
|
|
358
|
+
call_control_id: string;
|
|
359
|
+
call_leg_id: string;
|
|
360
|
+
call_session_id: string;
|
|
361
|
+
is_alive: boolean;
|
|
362
|
+
record_type: string;
|
|
363
|
+
};
|
|
364
|
+
}
|