@openclaw/voice-call 2026.1.29 → 2026.2.1
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 +31 -0
- package/README.md +13 -9
- package/index.ts +45 -49
- package/openclaw.plugin.json +11 -53
- package/package.json +6 -3
- package/src/cli.ts +80 -113
- package/src/config.test.ts +1 -4
- package/src/config.ts +88 -110
- package/src/core-bridge.ts +14 -12
- package/src/manager/context.ts +1 -1
- package/src/manager/events.ts +18 -9
- package/src/manager/lookup.ts +3 -1
- package/src/manager/outbound.ts +46 -19
- package/src/manager/state.ts +4 -6
- package/src/manager/store.ts +6 -3
- package/src/manager/timers.ts +11 -8
- package/src/manager.test.ts +7 -10
- package/src/manager.ts +53 -75
- package/src/media-stream.test.ts +0 -1
- package/src/media-stream.ts +12 -26
- package/src/providers/mock.ts +13 -16
- package/src/providers/plivo.test.ts +0 -1
- package/src/providers/plivo.ts +27 -29
- package/src/providers/stt-openai-realtime.ts +8 -8
- package/src/providers/telnyx.ts +5 -11
- package/src/providers/tts-openai.ts +9 -14
- package/src/providers/twilio/api.ts +9 -12
- package/src/providers/twilio/webhook.ts +2 -4
- package/src/providers/twilio.test.ts +1 -5
- package/src/providers/twilio.ts +34 -46
- package/src/response-generator.ts +7 -20
- package/src/runtime.ts +12 -25
- package/src/telephony-audio.ts +14 -12
- package/src/telephony-tts.ts +21 -12
- package/src/tunnel.ts +7 -24
- package/src/types.ts +0 -1
- package/src/utils.ts +3 -1
- package/src/voice-mapping.ts +3 -1
- package/src/webhook-security.test.ts +12 -21
- package/src/webhook-security.ts +25 -29
- package/src/webhook.ts +22 -57
package/src/media-stream.ts
CHANGED
|
@@ -9,9 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { IncomingMessage } from "node:http";
|
|
11
11
|
import type { Duplex } from "node:stream";
|
|
12
|
-
|
|
13
12
|
import { WebSocket, WebSocketServer } from "ws";
|
|
14
|
-
|
|
15
13
|
import type {
|
|
16
14
|
OpenAIRealtimeSTTProvider,
|
|
17
15
|
RealtimeSTTSession,
|
|
@@ -87,10 +85,7 @@ export class MediaStreamHandler {
|
|
|
87
85
|
/**
|
|
88
86
|
* Handle new WebSocket connection from Twilio.
|
|
89
87
|
*/
|
|
90
|
-
private async handleConnection(
|
|
91
|
-
ws: WebSocket,
|
|
92
|
-
_request: IncomingMessage,
|
|
93
|
-
): Promise<void> {
|
|
88
|
+
private async handleConnection(ws: WebSocket, _request: IncomingMessage): Promise<void> {
|
|
94
89
|
let session: StreamSession | null = null;
|
|
95
90
|
|
|
96
91
|
ws.on("message", async (data: Buffer) => {
|
|
@@ -140,16 +135,11 @@ export class MediaStreamHandler {
|
|
|
140
135
|
/**
|
|
141
136
|
* Handle stream start event.
|
|
142
137
|
*/
|
|
143
|
-
private async handleStart(
|
|
144
|
-
ws: WebSocket,
|
|
145
|
-
message: TwilioMediaMessage,
|
|
146
|
-
): Promise<StreamSession> {
|
|
138
|
+
private async handleStart(ws: WebSocket, message: TwilioMediaMessage): Promise<StreamSession> {
|
|
147
139
|
const streamSid = message.streamSid || "";
|
|
148
140
|
const callSid = message.start?.callSid || "";
|
|
149
141
|
|
|
150
|
-
console.log(
|
|
151
|
-
`[MediaStream] Stream started: ${streamSid} (call: ${callSid})`,
|
|
152
|
-
);
|
|
142
|
+
console.log(`[MediaStream] Stream started: ${streamSid} (call: ${callSid})`);
|
|
153
143
|
|
|
154
144
|
// Create STT session
|
|
155
145
|
const sttSession = this.config.sttProvider.createSession();
|
|
@@ -181,10 +171,7 @@ export class MediaStreamHandler {
|
|
|
181
171
|
|
|
182
172
|
// Connect to OpenAI STT (non-blocking, log errors but don't fail the call)
|
|
183
173
|
sttSession.connect().catch((err) => {
|
|
184
|
-
console.warn(
|
|
185
|
-
`[MediaStream] STT connection failed (TTS still works):`,
|
|
186
|
-
err.message,
|
|
187
|
-
);
|
|
174
|
+
console.warn(`[MediaStream] STT connection failed (TTS still works):`, err.message);
|
|
188
175
|
});
|
|
189
176
|
|
|
190
177
|
return session;
|
|
@@ -252,10 +239,7 @@ export class MediaStreamHandler {
|
|
|
252
239
|
* Queue a TTS operation for sequential playback.
|
|
253
240
|
* Only one TTS operation plays at a time per stream to prevent overlap.
|
|
254
241
|
*/
|
|
255
|
-
async queueTts(
|
|
256
|
-
streamSid: string,
|
|
257
|
-
playFn: (signal: AbortSignal) => Promise<void>,
|
|
258
|
-
): Promise<void> {
|
|
242
|
+
async queueTts(streamSid: string, playFn: (signal: AbortSignal) => Promise<void>): Promise<void> {
|
|
259
243
|
const queue = this.getTtsQueue(streamSid);
|
|
260
244
|
let resolveEntry: () => void;
|
|
261
245
|
let rejectEntry: (error: unknown) => void;
|
|
@@ -292,9 +276,7 @@ export class MediaStreamHandler {
|
|
|
292
276
|
* Get active session by call ID.
|
|
293
277
|
*/
|
|
294
278
|
getSessionByCallId(callId: string): StreamSession | undefined {
|
|
295
|
-
return [...this.sessions.values()].find(
|
|
296
|
-
(session) => session.callId === callId,
|
|
297
|
-
);
|
|
279
|
+
return [...this.sessions.values()].find((session) => session.callId === callId);
|
|
298
280
|
}
|
|
299
281
|
|
|
300
282
|
/**
|
|
@@ -311,7 +293,9 @@ export class MediaStreamHandler {
|
|
|
311
293
|
|
|
312
294
|
private getTtsQueue(streamSid: string): TtsQueueEntry[] {
|
|
313
295
|
const existing = this.ttsQueues.get(streamSid);
|
|
314
|
-
if (existing)
|
|
296
|
+
if (existing) {
|
|
297
|
+
return existing;
|
|
298
|
+
}
|
|
315
299
|
const queue: TtsQueueEntry[] = [];
|
|
316
300
|
this.ttsQueues.set(streamSid, queue);
|
|
317
301
|
return queue;
|
|
@@ -355,7 +339,9 @@ export class MediaStreamHandler {
|
|
|
355
339
|
|
|
356
340
|
private clearTtsState(streamSid: string): void {
|
|
357
341
|
const queue = this.ttsQueues.get(streamSid);
|
|
358
|
-
if (queue)
|
|
342
|
+
if (queue) {
|
|
343
|
+
queue.length = 0;
|
|
344
|
+
}
|
|
359
345
|
this.ttsActiveControllers.get(streamSid)?.abort();
|
|
360
346
|
this.ttsActiveControllers.delete(streamSid);
|
|
361
347
|
this.ttsPlaying.delete(streamSid);
|
package/src/providers/mock.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
|
|
3
2
|
import type {
|
|
4
3
|
EndReason,
|
|
5
4
|
HangupCallInput,
|
|
@@ -37,11 +36,15 @@ export class MockProvider implements VoiceCallProvider {
|
|
|
37
36
|
if (Array.isArray(payload.events)) {
|
|
38
37
|
for (const evt of payload.events) {
|
|
39
38
|
const normalized = this.normalizeEvent(evt);
|
|
40
|
-
if (normalized)
|
|
39
|
+
if (normalized) {
|
|
40
|
+
events.push(normalized);
|
|
41
|
+
}
|
|
41
42
|
}
|
|
42
43
|
} else if (payload.event) {
|
|
43
44
|
const normalized = this.normalizeEvent(payload.event);
|
|
44
|
-
if (normalized)
|
|
45
|
+
if (normalized) {
|
|
46
|
+
events.push(normalized);
|
|
47
|
+
}
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
return { events, statusCode: 200 };
|
|
@@ -50,10 +53,10 @@ export class MockProvider implements VoiceCallProvider {
|
|
|
50
53
|
}
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
private normalizeEvent(
|
|
54
|
-
evt
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
private normalizeEvent(evt: Partial<NormalizedEvent>): NormalizedEvent | null {
|
|
57
|
+
if (!evt.type || !evt.callId) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
57
60
|
|
|
58
61
|
const base = {
|
|
59
62
|
id: evt.id || crypto.randomUUID(),
|
|
@@ -96,9 +99,7 @@ export class MockProvider implements VoiceCallProvider {
|
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
case "call.silence": {
|
|
99
|
-
const payload = evt as Partial<
|
|
100
|
-
NormalizedEvent & { durationMs?: number }
|
|
101
|
-
>;
|
|
102
|
+
const payload = evt as Partial<NormalizedEvent & { durationMs?: number }>;
|
|
102
103
|
return {
|
|
103
104
|
...base,
|
|
104
105
|
type: evt.type,
|
|
@@ -116,9 +117,7 @@ export class MockProvider implements VoiceCallProvider {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
case "call.ended": {
|
|
119
|
-
const payload = evt as Partial<
|
|
120
|
-
NormalizedEvent & { reason?: EndReason }
|
|
121
|
-
>;
|
|
120
|
+
const payload = evt as Partial<NormalizedEvent & { reason?: EndReason }>;
|
|
122
121
|
return {
|
|
123
122
|
...base,
|
|
124
123
|
type: evt.type,
|
|
@@ -127,9 +126,7 @@ export class MockProvider implements VoiceCallProvider {
|
|
|
127
126
|
}
|
|
128
127
|
|
|
129
128
|
case "call.error": {
|
|
130
|
-
const payload = evt as Partial<
|
|
131
|
-
NormalizedEvent & { error?: string; retryable?: boolean }
|
|
132
|
-
>;
|
|
129
|
+
const payload = evt as Partial<NormalizedEvent & { error?: string; retryable?: boolean }>;
|
|
133
130
|
return {
|
|
134
131
|
...base,
|
|
135
132
|
type: evt.type,
|
package/src/providers/plivo.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
|
|
3
2
|
import type { PlivoConfig } from "../config.js";
|
|
4
3
|
import type {
|
|
5
4
|
HangupCallInput,
|
|
@@ -13,9 +12,9 @@ import type {
|
|
|
13
12
|
WebhookContext,
|
|
14
13
|
WebhookVerificationResult,
|
|
15
14
|
} from "../types.js";
|
|
15
|
+
import type { VoiceCallProvider } from "./base.js";
|
|
16
16
|
import { escapeXml } from "../voice-mapping.js";
|
|
17
17
|
import { reconstructWebhookUrl, verifyPlivoWebhook } from "../webhook-security.js";
|
|
18
|
-
import type { VoiceCallProvider } from "./base.js";
|
|
19
18
|
|
|
20
19
|
export interface PlivoProviderOptions {
|
|
21
20
|
/** Override public URL origin for signature verification */
|
|
@@ -103,8 +102,7 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
103
102
|
}
|
|
104
103
|
|
|
105
104
|
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
|
|
106
|
-
const flow =
|
|
107
|
-
typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
|
|
105
|
+
const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
|
|
108
106
|
|
|
109
107
|
const parsed = this.parseBody(ctx.rawBody);
|
|
110
108
|
if (!parsed) {
|
|
@@ -124,7 +122,9 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
124
122
|
if (flow === "xml-speak") {
|
|
125
123
|
const callId = this.getCallIdFromQuery(ctx);
|
|
126
124
|
const pending = callId ? this.pendingSpeakByCallId.get(callId) : undefined;
|
|
127
|
-
if (callId)
|
|
125
|
+
if (callId) {
|
|
126
|
+
this.pendingSpeakByCallId.delete(callId);
|
|
127
|
+
}
|
|
128
128
|
|
|
129
129
|
const xml = pending
|
|
130
130
|
? PlivoProvider.xmlSpeak(pending.text, pending.locale)
|
|
@@ -139,10 +139,10 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
139
139
|
|
|
140
140
|
if (flow === "xml-listen") {
|
|
141
141
|
const callId = this.getCallIdFromQuery(ctx);
|
|
142
|
-
const pending = callId
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
const pending = callId ? this.pendingListenByCallId.get(callId) : undefined;
|
|
143
|
+
if (callId) {
|
|
144
|
+
this.pendingListenByCallId.delete(callId);
|
|
145
|
+
}
|
|
146
146
|
|
|
147
147
|
const actionUrl = this.buildActionUrl(ctx, {
|
|
148
148
|
flow: "getinput",
|
|
@@ -180,10 +180,7 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
180
180
|
};
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
private normalizeEvent(
|
|
184
|
-
params: URLSearchParams,
|
|
185
|
-
callIdOverride?: string,
|
|
186
|
-
): NormalizedEvent | null {
|
|
183
|
+
private normalizeEvent(params: URLSearchParams, callIdOverride?: string): NormalizedEvent | null {
|
|
187
184
|
const callUuid = params.get("CallUUID") || "";
|
|
188
185
|
const requestUuid = params.get("RequestUUID") || "";
|
|
189
186
|
|
|
@@ -329,11 +326,9 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
329
326
|
}
|
|
330
327
|
|
|
331
328
|
async playTts(input: PlayTtsInput): Promise<void> {
|
|
332
|
-
const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ??
|
|
333
|
-
input.providerCallId;
|
|
329
|
+
const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ?? input.providerCallId;
|
|
334
330
|
const webhookBase =
|
|
335
|
-
this.callUuidToWebhookUrl.get(callUuid) ||
|
|
336
|
-
this.callIdToWebhookUrl.get(input.callId);
|
|
331
|
+
this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(input.callId);
|
|
337
332
|
if (!webhookBase) {
|
|
338
333
|
throw new Error("Missing webhook URL for this call (provider state missing)");
|
|
339
334
|
}
|
|
@@ -364,11 +359,9 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
364
359
|
}
|
|
365
360
|
|
|
366
361
|
async startListening(input: StartListeningInput): Promise<void> {
|
|
367
|
-
const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ??
|
|
368
|
-
input.providerCallId;
|
|
362
|
+
const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ?? input.providerCallId;
|
|
369
363
|
const webhookBase =
|
|
370
|
-
this.callUuidToWebhookUrl.get(callUuid) ||
|
|
371
|
-
this.callIdToWebhookUrl.get(input.callId);
|
|
364
|
+
this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(input.callId);
|
|
372
365
|
if (!webhookBase) {
|
|
373
366
|
throw new Error("Missing webhook URL for this call (provider state missing)");
|
|
374
367
|
}
|
|
@@ -403,7 +396,9 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
403
396
|
|
|
404
397
|
private static normalizeNumber(numberOrSip: string): string {
|
|
405
398
|
const trimmed = numberOrSip.trim();
|
|
406
|
-
if (trimmed.toLowerCase().startsWith("sip:"))
|
|
399
|
+
if (trimmed.toLowerCase().startsWith("sip:")) {
|
|
400
|
+
return trimmed;
|
|
401
|
+
}
|
|
407
402
|
return trimmed.replace(/[^\d+]/g, "");
|
|
408
403
|
}
|
|
409
404
|
|
|
@@ -427,10 +422,7 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
427
422
|
</Response>`;
|
|
428
423
|
}
|
|
429
424
|
|
|
430
|
-
private static xmlGetInputSpeech(params: {
|
|
431
|
-
actionUrl: string;
|
|
432
|
-
language?: string;
|
|
433
|
-
}): string {
|
|
425
|
+
private static xmlGetInputSpeech(params: { actionUrl: string; language?: string }): string {
|
|
434
426
|
const language = params.language || "en-US";
|
|
435
427
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
436
428
|
<Response>
|
|
@@ -453,12 +445,16 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
453
445
|
opts: { flow: string; callId?: string },
|
|
454
446
|
): string | null {
|
|
455
447
|
const base = PlivoProvider.baseWebhookUrlFromCtx(ctx);
|
|
456
|
-
if (!base)
|
|
448
|
+
if (!base) {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
457
451
|
|
|
458
452
|
const u = new URL(base);
|
|
459
453
|
u.searchParams.set("provider", "plivo");
|
|
460
454
|
u.searchParams.set("flow", opts.flow);
|
|
461
|
-
if (opts.callId)
|
|
455
|
+
if (opts.callId) {
|
|
456
|
+
u.searchParams.set("callId", opts.callId);
|
|
457
|
+
}
|
|
462
458
|
return u.toString();
|
|
463
459
|
}
|
|
464
460
|
|
|
@@ -491,7 +487,9 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
491
487
|
|
|
492
488
|
for (const key of candidates) {
|
|
493
489
|
const value = params.get(key);
|
|
494
|
-
if (value && value.trim())
|
|
490
|
+
if (value && value.trim()) {
|
|
491
|
+
return value.trim();
|
|
492
|
+
}
|
|
495
493
|
}
|
|
496
494
|
return null;
|
|
497
495
|
}
|
|
@@ -155,7 +155,9 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
|
|
155
155
|
|
|
156
156
|
this.ws.on("error", (error) => {
|
|
157
157
|
console.error("[RealtimeSTT] WebSocket error:", error);
|
|
158
|
-
if (!this.connected)
|
|
158
|
+
if (!this.connected) {
|
|
159
|
+
reject(error);
|
|
160
|
+
}
|
|
159
161
|
});
|
|
160
162
|
|
|
161
163
|
this.ws.on("close", (code, reason) => {
|
|
@@ -183,9 +185,7 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
|
|
183
185
|
return;
|
|
184
186
|
}
|
|
185
187
|
|
|
186
|
-
if (
|
|
187
|
-
this.reconnectAttempts >= OpenAIRealtimeSTTSession.MAX_RECONNECT_ATTEMPTS
|
|
188
|
-
) {
|
|
188
|
+
if (this.reconnectAttempts >= OpenAIRealtimeSTTSession.MAX_RECONNECT_ATTEMPTS) {
|
|
189
189
|
console.error(
|
|
190
190
|
`[RealtimeSTT] Max reconnect attempts (${OpenAIRealtimeSTTSession.MAX_RECONNECT_ATTEMPTS}) reached`,
|
|
191
191
|
);
|
|
@@ -193,9 +193,7 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
this.reconnectAttempts++;
|
|
196
|
-
const delay =
|
|
197
|
-
OpenAIRealtimeSTTSession.RECONNECT_DELAY_MS *
|
|
198
|
-
2 ** (this.reconnectAttempts - 1);
|
|
196
|
+
const delay = OpenAIRealtimeSTTSession.RECONNECT_DELAY_MS * 2 ** (this.reconnectAttempts - 1);
|
|
199
197
|
console.log(
|
|
200
198
|
`[RealtimeSTT] Reconnecting ${this.reconnectAttempts}/${OpenAIRealtimeSTTSession.MAX_RECONNECT_ATTEMPTS} in ${delay}ms...`,
|
|
201
199
|
);
|
|
@@ -262,7 +260,9 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
|
|
262
260
|
}
|
|
263
261
|
|
|
264
262
|
sendAudio(muLawData: Buffer): void {
|
|
265
|
-
if (!this.connected)
|
|
263
|
+
if (!this.connected) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
266
|
this.sendEvent({
|
|
267
267
|
type: "input_audio_buffer.append",
|
|
268
268
|
audio: muLawData.toString("base64"),
|
package/src/providers/telnyx.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
|
|
3
2
|
import type { TelnyxConfig } from "../config.js";
|
|
4
3
|
import type {
|
|
5
4
|
EndReason,
|
|
@@ -161,9 +160,7 @@ export class TelnyxProvider implements VoiceCallProvider {
|
|
|
161
160
|
let callId = "";
|
|
162
161
|
if (data.payload?.client_state) {
|
|
163
162
|
try {
|
|
164
|
-
callId = Buffer.from(data.payload.client_state, "base64").toString(
|
|
165
|
-
"utf8",
|
|
166
|
-
);
|
|
163
|
+
callId = Buffer.from(data.payload.client_state, "base64").toString("utf8");
|
|
167
164
|
} catch {
|
|
168
165
|
// Fallback if not valid Base64
|
|
169
166
|
callId = data.payload.client_state;
|
|
@@ -312,13 +309,10 @@ export class TelnyxProvider implements VoiceCallProvider {
|
|
|
312
309
|
* Start transcription (STT) via Telnyx.
|
|
313
310
|
*/
|
|
314
311
|
async startListening(input: StartListeningInput): Promise<void> {
|
|
315
|
-
await this.apiRequest(
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
language: input.language || "en",
|
|
320
|
-
},
|
|
321
|
-
);
|
|
312
|
+
await this.apiRequest(`/calls/${input.providerCallId}/actions/transcription_start`, {
|
|
313
|
+
command_id: crypto.randomUUID(),
|
|
314
|
+
language: input.language || "en",
|
|
315
|
+
});
|
|
322
316
|
}
|
|
323
317
|
|
|
324
318
|
/**
|
|
@@ -84,9 +84,7 @@ export class OpenAITTSProvider {
|
|
|
84
84
|
this.instructions = config.instructions;
|
|
85
85
|
|
|
86
86
|
if (!this.apiKey) {
|
|
87
|
-
throw new Error(
|
|
88
|
-
"OpenAI API key required (set OPENAI_API_KEY or pass apiKey)",
|
|
89
|
-
);
|
|
87
|
+
throw new Error("OpenAI API key required (set OPENAI_API_KEY or pass apiKey)");
|
|
90
88
|
}
|
|
91
89
|
}
|
|
92
90
|
|
|
@@ -207,19 +205,19 @@ function linearToMulaw(sample: number): number {
|
|
|
207
205
|
|
|
208
206
|
// Get sign bit
|
|
209
207
|
const sign = sample < 0 ? 0x80 : 0;
|
|
210
|
-
if (sample < 0)
|
|
208
|
+
if (sample < 0) {
|
|
209
|
+
sample = -sample;
|
|
210
|
+
}
|
|
211
211
|
|
|
212
212
|
// Clip to prevent overflow
|
|
213
|
-
if (sample > CLIP)
|
|
213
|
+
if (sample > CLIP) {
|
|
214
|
+
sample = CLIP;
|
|
215
|
+
}
|
|
214
216
|
|
|
215
217
|
// Add bias and find segment
|
|
216
218
|
sample += BIAS;
|
|
217
219
|
let exponent = 7;
|
|
218
|
-
for (
|
|
219
|
-
let expMask = 0x4000;
|
|
220
|
-
(sample & expMask) === 0 && exponent > 0;
|
|
221
|
-
exponent--, expMask >>= 1
|
|
222
|
-
) {
|
|
220
|
+
for (let expMask = 0x4000; (sample & expMask) === 0 && exponent > 0; exponent--, expMask >>= 1) {
|
|
223
221
|
// Find the segment (exponent)
|
|
224
222
|
}
|
|
225
223
|
|
|
@@ -252,10 +250,7 @@ export function mulawToLinear(mulaw: number): number {
|
|
|
252
250
|
* Chunk audio buffer into 20ms frames for streaming.
|
|
253
251
|
* At 8kHz mono, 20ms = 160 samples = 160 bytes (mu-law).
|
|
254
252
|
*/
|
|
255
|
-
export function chunkAudio(
|
|
256
|
-
audio: Buffer,
|
|
257
|
-
chunkSize = 160,
|
|
258
|
-
): Generator<Buffer, void, unknown> {
|
|
253
|
+
export function chunkAudio(audio: Buffer, chunkSize = 160): Generator<Buffer, void, unknown> {
|
|
259
254
|
return (function* () {
|
|
260
255
|
for (let i = 0; i < audio.length; i += chunkSize) {
|
|
261
256
|
yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
|
|
@@ -9,19 +9,16 @@ export async function twilioApiRequest<T = unknown>(params: {
|
|
|
9
9
|
const bodyParams =
|
|
10
10
|
params.body instanceof URLSearchParams
|
|
11
11
|
? params.body
|
|
12
|
-
: Object.entries(params.body).reduce<URLSearchParams>(
|
|
13
|
-
(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
acc.append(key, entry);
|
|
17
|
-
}
|
|
18
|
-
} else if (typeof value === "string") {
|
|
19
|
-
acc.append(key, value);
|
|
12
|
+
: Object.entries(params.body).reduce<URLSearchParams>((acc, [key, value]) => {
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
for (const entry of value) {
|
|
15
|
+
acc.append(key, entry);
|
|
20
16
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
} else if (typeof value === "string") {
|
|
18
|
+
acc.append(key, value);
|
|
19
|
+
}
|
|
20
|
+
return acc;
|
|
21
|
+
}, new URLSearchParams());
|
|
25
22
|
|
|
26
23
|
const response = await fetch(`${params.baseUrl}${params.endpoint}`, {
|
|
27
24
|
method: "POST",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { WebhookContext, WebhookVerificationResult } from "../../types.js";
|
|
2
|
-
import { verifyTwilioWebhook } from "../../webhook-security.js";
|
|
3
|
-
|
|
4
2
|
import type { TwilioProviderOptions } from "../twilio.js";
|
|
3
|
+
import { verifyTwilioWebhook } from "../../webhook-security.js";
|
|
5
4
|
|
|
6
5
|
export function verifyTwilioProviderWebhook(params: {
|
|
7
6
|
ctx: WebhookContext;
|
|
@@ -11,8 +10,7 @@ export function verifyTwilioProviderWebhook(params: {
|
|
|
11
10
|
}): WebhookVerificationResult {
|
|
12
11
|
const result = verifyTwilioWebhook(params.ctx, params.authToken, {
|
|
13
12
|
publicUrl: params.currentPublicUrl || undefined,
|
|
14
|
-
allowNgrokFreeTierLoopbackBypass:
|
|
15
|
-
params.options.allowNgrokFreeTierLoopbackBypass ?? false,
|
|
13
|
+
allowNgrokFreeTierLoopbackBypass: params.options.allowNgrokFreeTierLoopbackBypass ?? false,
|
|
16
14
|
skipVerification: params.options.skipVerification,
|
|
17
15
|
});
|
|
18
16
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
2
|
import type { WebhookContext } from "../types.js";
|
|
4
3
|
import { TwilioProvider } from "./twilio.js";
|
|
5
4
|
|
|
@@ -12,10 +11,7 @@ function createProvider(): TwilioProvider {
|
|
|
12
11
|
);
|
|
13
12
|
}
|
|
14
13
|
|
|
15
|
-
function createContext(
|
|
16
|
-
rawBody: string,
|
|
17
|
-
query?: WebhookContext["query"],
|
|
18
|
-
): WebhookContext {
|
|
14
|
+
function createContext(rawBody: string, query?: WebhookContext["query"]): WebhookContext {
|
|
19
15
|
return {
|
|
20
16
|
headers: {},
|
|
21
17
|
rawBody,
|