@skillhq/concierge 1.5.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/README.md +91 -0
- package/dist/cli/program.d.ts +3 -0
- package/dist/cli/program.d.ts.map +1 -0
- package/dist/cli/program.js +46 -0
- package/dist/cli/program.js.map +1 -0
- package/dist/cli/shared.d.ts +18 -0
- package/dist/cli/shared.d.ts.map +1 -0
- package/dist/cli/shared.js +2 -0
- package/dist/cli/shared.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +5 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/call.d.ts +7 -0
- package/dist/commands/call.d.ts.map +1 -0
- package/dist/commands/call.js +409 -0
- package/dist/commands/call.js.map +1 -0
- package/dist/commands/config.d.ts +4 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +120 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/find-contact.d.ts +4 -0
- package/dist/commands/find-contact.d.ts.map +1 -0
- package/dist/commands/find-contact.js +57 -0
- package/dist/commands/find-contact.js.map +1 -0
- package/dist/commands/server.d.ts +7 -0
- package/dist/commands/server.d.ts.map +1 -0
- package/dist/commands/server.js +212 -0
- package/dist/commands/server.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/call/audio/mulaw.d.ts +35 -0
- package/dist/lib/call/audio/mulaw.d.ts.map +1 -0
- package/dist/lib/call/audio/mulaw.js +109 -0
- package/dist/lib/call/audio/mulaw.js.map +1 -0
- package/dist/lib/call/audio/pcm-utils.d.ts +62 -0
- package/dist/lib/call/audio/pcm-utils.d.ts.map +1 -0
- package/dist/lib/call/audio/pcm-utils.js +149 -0
- package/dist/lib/call/audio/pcm-utils.js.map +1 -0
- package/dist/lib/call/audio/resample.d.ts +34 -0
- package/dist/lib/call/audio/resample.d.ts.map +1 -0
- package/dist/lib/call/audio/resample.js +97 -0
- package/dist/lib/call/audio/resample.js.map +1 -0
- package/dist/lib/call/audio/streaming-decoder.d.ts +45 -0
- package/dist/lib/call/audio/streaming-decoder.d.ts.map +1 -0
- package/dist/lib/call/audio/streaming-decoder.js +110 -0
- package/dist/lib/call/audio/streaming-decoder.js.map +1 -0
- package/dist/lib/call/call-server.d.ts +110 -0
- package/dist/lib/call/call-server.d.ts.map +1 -0
- package/dist/lib/call/call-server.js +681 -0
- package/dist/lib/call/call-server.js.map +1 -0
- package/dist/lib/call/call-session.d.ts +133 -0
- package/dist/lib/call/call-session.d.ts.map +1 -0
- package/dist/lib/call/call-session.js +890 -0
- package/dist/lib/call/call-session.js.map +1 -0
- package/dist/lib/call/call-types.d.ts +133 -0
- package/dist/lib/call/call-types.d.ts.map +1 -0
- package/dist/lib/call/call-types.js +16 -0
- package/dist/lib/call/call-types.js.map +1 -0
- package/dist/lib/call/conversation-ai.d.ts +56 -0
- package/dist/lib/call/conversation-ai.d.ts.map +1 -0
- package/dist/lib/call/conversation-ai.js +276 -0
- package/dist/lib/call/conversation-ai.js.map +1 -0
- package/dist/lib/call/eval/codec-test.d.ts +45 -0
- package/dist/lib/call/eval/codec-test.d.ts.map +1 -0
- package/dist/lib/call/eval/codec-test.js +169 -0
- package/dist/lib/call/eval/codec-test.js.map +1 -0
- package/dist/lib/call/eval/conversation-scripts.d.ts +55 -0
- package/dist/lib/call/eval/conversation-scripts.d.ts.map +1 -0
- package/dist/lib/call/eval/conversation-scripts.js +359 -0
- package/dist/lib/call/eval/conversation-scripts.js.map +1 -0
- package/dist/lib/call/eval/eval-runner.d.ts +64 -0
- package/dist/lib/call/eval/eval-runner.d.ts.map +1 -0
- package/dist/lib/call/eval/eval-runner.js +369 -0
- package/dist/lib/call/eval/eval-runner.js.map +1 -0
- package/dist/lib/call/eval/index.d.ts +9 -0
- package/dist/lib/call/eval/index.d.ts.map +1 -0
- package/dist/lib/call/eval/index.js +9 -0
- package/dist/lib/call/eval/index.js.map +1 -0
- package/dist/lib/call/eval/integration-test-suite.d.ts +71 -0
- package/dist/lib/call/eval/integration-test-suite.d.ts.map +1 -0
- package/dist/lib/call/eval/integration-test-suite.js +519 -0
- package/dist/lib/call/eval/integration-test-suite.js.map +1 -0
- package/dist/lib/call/eval/turn-taking-test.d.ts +84 -0
- package/dist/lib/call/eval/turn-taking-test.d.ts.map +1 -0
- package/dist/lib/call/eval/turn-taking-test.js +260 -0
- package/dist/lib/call/eval/turn-taking-test.js.map +1 -0
- package/dist/lib/call/index.d.ts +12 -0
- package/dist/lib/call/index.d.ts.map +1 -0
- package/dist/lib/call/index.js +17 -0
- package/dist/lib/call/index.js.map +1 -0
- package/dist/lib/call/providers/deepgram.d.ts +81 -0
- package/dist/lib/call/providers/deepgram.d.ts.map +1 -0
- package/dist/lib/call/providers/deepgram.js +279 -0
- package/dist/lib/call/providers/deepgram.js.map +1 -0
- package/dist/lib/call/providers/elevenlabs.d.ts +78 -0
- package/dist/lib/call/providers/elevenlabs.d.ts.map +1 -0
- package/dist/lib/call/providers/elevenlabs.js +272 -0
- package/dist/lib/call/providers/elevenlabs.js.map +1 -0
- package/dist/lib/call/providers/local-deps.d.ts +18 -0
- package/dist/lib/call/providers/local-deps.d.ts.map +1 -0
- package/dist/lib/call/providers/local-deps.js +114 -0
- package/dist/lib/call/providers/local-deps.js.map +1 -0
- package/dist/lib/call/providers/twilio.d.ts +53 -0
- package/dist/lib/call/providers/twilio.d.ts.map +1 -0
- package/dist/lib/call/providers/twilio.js +173 -0
- package/dist/lib/call/providers/twilio.js.map +1 -0
- package/dist/lib/concierge-client-types.d.ts +68 -0
- package/dist/lib/concierge-client-types.d.ts.map +1 -0
- package/dist/lib/concierge-client-types.js +2 -0
- package/dist/lib/concierge-client-types.js.map +1 -0
- package/dist/lib/concierge-client.d.ts +29 -0
- package/dist/lib/concierge-client.d.ts.map +1 -0
- package/dist/lib/concierge-client.js +534 -0
- package/dist/lib/concierge-client.js.map +1 -0
- package/dist/lib/config.d.ts +9 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +66 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/output.d.ts +7 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +114 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/utils/contact-extractor.d.ts +12 -0
- package/dist/lib/utils/contact-extractor.d.ts.map +1 -0
- package/dist/lib/utils/contact-extractor.js +159 -0
- package/dist/lib/utils/contact-extractor.js.map +1 -0
- package/dist/lib/utils/formatters.d.ts +15 -0
- package/dist/lib/utils/formatters.d.ts.map +1 -0
- package/dist/lib/utils/formatters.js +107 -0
- package/dist/lib/utils/formatters.js.map +1 -0
- package/dist/lib/utils/url-parser.d.ts +11 -0
- package/dist/lib/utils/url-parser.d.ts.map +1 -0
- package/dist/lib/utils/url-parser.js +103 -0
- package/dist/lib/utils/url-parser.js.map +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call server - HTTP + WebSocket server for voice calls
|
|
3
|
+
*/
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
8
|
+
import { CallSession } from './call-session.js';
|
|
9
|
+
import { preflightDeepgramSTT } from './providers/deepgram.js';
|
|
10
|
+
import { preflightElevenLabsTTSBudget } from './providers/elevenlabs.js';
|
|
11
|
+
import { preflightFfmpeg } from './providers/local-deps.js';
|
|
12
|
+
import { formatPhoneNumber, generateErrorTwiml, generateMediaStreamsTwiml, getCallStatus, initiateCall, parseWebhookBody, preflightTwilioCallSetup, validateWebhookSignature, } from './providers/twilio.js';
|
|
13
|
+
// Maximum request body size (1MB)
|
|
14
|
+
const MAX_BODY_SIZE = 1024 * 1024;
|
|
15
|
+
// Maximum lengths for call request fields
|
|
16
|
+
const MAX_PHONE_LENGTH = 20;
|
|
17
|
+
const MAX_GOAL_LENGTH = 1000;
|
|
18
|
+
const MAX_CONTEXT_LENGTH = 5000;
|
|
19
|
+
const TERMINAL_CALL_STATUSES = new Set(['completed', 'busy', 'failed', 'no-answer', 'canceled']);
|
|
20
|
+
const STATUS_RECONCILE_INTERVAL_MS = 10000;
|
|
21
|
+
const PUBLIC_WEBHOOK_PREFLIGHT_TIMEOUT_MS = 6000;
|
|
22
|
+
export class CallServer extends EventEmitter {
|
|
23
|
+
server = null;
|
|
24
|
+
controlWss = null;
|
|
25
|
+
mediaWss = null;
|
|
26
|
+
options;
|
|
27
|
+
sessions = new Map();
|
|
28
|
+
controlClients = new Set();
|
|
29
|
+
statusReconcileTimer = null;
|
|
30
|
+
isPreflightCallId(callId) {
|
|
31
|
+
return !!callId && callId.startsWith('preflight-');
|
|
32
|
+
}
|
|
33
|
+
constructor(options) {
|
|
34
|
+
super();
|
|
35
|
+
this.options = options;
|
|
36
|
+
}
|
|
37
|
+
timestamp() {
|
|
38
|
+
return new Date().toISOString();
|
|
39
|
+
}
|
|
40
|
+
log(message) {
|
|
41
|
+
console.log(`[${this.timestamp()}] ${message}`);
|
|
42
|
+
}
|
|
43
|
+
warn(message) {
|
|
44
|
+
console.warn(`[${this.timestamp()}] ${message}`);
|
|
45
|
+
}
|
|
46
|
+
error(message, error) {
|
|
47
|
+
if (error !== undefined) {
|
|
48
|
+
console.error(`[${this.timestamp()}] ${message}`, error);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
console.error(`[${this.timestamp()}] ${message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Start the server
|
|
56
|
+
*/
|
|
57
|
+
async start() {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
try {
|
|
60
|
+
// Create HTTP server
|
|
61
|
+
this.server = createServer((req, res) => this.handleHttpRequest(req, res));
|
|
62
|
+
// Create WebSocket servers
|
|
63
|
+
this.controlWss = new WebSocketServer({ noServer: true });
|
|
64
|
+
this.mediaWss = new WebSocketServer({ noServer: true });
|
|
65
|
+
// Handle WebSocket upgrades
|
|
66
|
+
this.server.on('upgrade', (request, socket, head) => {
|
|
67
|
+
const url = new URL(request.url ?? '/', `http://${request.headers.host}`);
|
|
68
|
+
const pathname = url.pathname;
|
|
69
|
+
this.log(`[Server] WebSocket upgrade request: ${pathname}`);
|
|
70
|
+
if (pathname === '/control') {
|
|
71
|
+
this.log('[Server] Handling /control WebSocket upgrade');
|
|
72
|
+
this.controlWss?.handleUpgrade(request, socket, head, (ws) => {
|
|
73
|
+
this.handleControlConnection(ws);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
else if (pathname.startsWith('/media-stream')) {
|
|
77
|
+
// Twilio doesn't pass query params in WebSocket URL - callId comes in 'start' event
|
|
78
|
+
this.log('[Server] Handling /media-stream WebSocket upgrade');
|
|
79
|
+
this.mediaWss?.handleUpgrade(request, socket, head, (ws) => {
|
|
80
|
+
this.handleMediaStreamConnection(ws);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
this.log(`[Server] Unknown WebSocket path: ${pathname}, destroying socket`);
|
|
85
|
+
socket.destroy();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
this.server.listen(this.options.port, () => {
|
|
89
|
+
this.log(`Call server listening on port ${this.options.port}`);
|
|
90
|
+
this.log(`Public URL: ${this.options.publicUrl}`);
|
|
91
|
+
this.startStatusReconcileLoop();
|
|
92
|
+
this.emit('started');
|
|
93
|
+
resolve();
|
|
94
|
+
});
|
|
95
|
+
this.server.on('error', (err) => {
|
|
96
|
+
this.emit('error', err);
|
|
97
|
+
reject(err);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
reject(err);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Stop the server
|
|
107
|
+
*/
|
|
108
|
+
async stop() {
|
|
109
|
+
if (this.statusReconcileTimer) {
|
|
110
|
+
clearInterval(this.statusReconcileTimer);
|
|
111
|
+
this.statusReconcileTimer = null;
|
|
112
|
+
}
|
|
113
|
+
// End all active calls
|
|
114
|
+
for (const session of this.sessions.values()) {
|
|
115
|
+
await session.hangup();
|
|
116
|
+
}
|
|
117
|
+
this.sessions.clear();
|
|
118
|
+
// Close control clients
|
|
119
|
+
for (const client of this.controlClients) {
|
|
120
|
+
client.close();
|
|
121
|
+
}
|
|
122
|
+
this.controlClients.clear();
|
|
123
|
+
// Close WebSocket servers
|
|
124
|
+
this.controlWss?.close();
|
|
125
|
+
this.mediaWss?.close();
|
|
126
|
+
// Close HTTP server
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
if (this.server) {
|
|
129
|
+
this.server.close(() => {
|
|
130
|
+
this.server = null;
|
|
131
|
+
this.emit('stopped');
|
|
132
|
+
resolve();
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
resolve();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Handle HTTP requests
|
|
142
|
+
*/
|
|
143
|
+
handleHttpRequest(req, res) {
|
|
144
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
145
|
+
const method = req.method ?? 'GET';
|
|
146
|
+
// CORS headers for local development
|
|
147
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
148
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
149
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
150
|
+
if (method === 'OPTIONS') {
|
|
151
|
+
res.writeHead(204);
|
|
152
|
+
res.end();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Route requests
|
|
156
|
+
if (method === 'GET' && url.pathname === '/health') {
|
|
157
|
+
this.handleHealthCheck(res);
|
|
158
|
+
}
|
|
159
|
+
else if (method === 'GET' && url.pathname === '/status') {
|
|
160
|
+
this.handleStatusCheck(res);
|
|
161
|
+
}
|
|
162
|
+
else if (method === 'POST' && url.pathname === '/call') {
|
|
163
|
+
this.handleCallRequest(req, res);
|
|
164
|
+
}
|
|
165
|
+
else if ((method === 'POST' || method === 'GET') && url.pathname === '/twilio/voice') {
|
|
166
|
+
this.handleTwilioVoice(req, res, url);
|
|
167
|
+
}
|
|
168
|
+
else if ((method === 'POST' || method === 'GET') && url.pathname === '/twilio/status') {
|
|
169
|
+
this.handleTwilioStatus(req, res, url);
|
|
170
|
+
}
|
|
171
|
+
else if (method === 'GET' && url.pathname.startsWith('/status/')) {
|
|
172
|
+
this.handleCallStatusCheck(res, url.pathname.split('/').pop() ?? '');
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
this.warn(`[HTTP] Unhandled request ${method} ${url.pathname}`);
|
|
176
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
177
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Health check endpoint
|
|
182
|
+
*/
|
|
183
|
+
handleHealthCheck(res) {
|
|
184
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
185
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Server status endpoint
|
|
189
|
+
*/
|
|
190
|
+
handleStatusCheck(res) {
|
|
191
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
192
|
+
res.end(JSON.stringify({
|
|
193
|
+
status: 'running',
|
|
194
|
+
activeCalls: this.sessions.size,
|
|
195
|
+
controlClients: this.controlClients.size,
|
|
196
|
+
publicUrl: this.options.publicUrl,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Call status endpoint
|
|
201
|
+
*/
|
|
202
|
+
handleCallStatusCheck(res, callId) {
|
|
203
|
+
const session = this.sessions.get(callId);
|
|
204
|
+
if (session) {
|
|
205
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
206
|
+
res.end(JSON.stringify(session.getState()));
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
210
|
+
res.end(JSON.stringify({ error: 'Call not found' }));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Initiate a new call via HTTP
|
|
215
|
+
*/
|
|
216
|
+
async handleCallRequest(req, res) {
|
|
217
|
+
let body = '';
|
|
218
|
+
let bodySize = 0;
|
|
219
|
+
req.on('data', (chunk) => {
|
|
220
|
+
bodySize += chunk.length;
|
|
221
|
+
if (bodySize > MAX_BODY_SIZE) {
|
|
222
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
223
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
224
|
+
req.destroy();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
body += chunk.toString();
|
|
228
|
+
});
|
|
229
|
+
req.on('end', async () => {
|
|
230
|
+
try {
|
|
231
|
+
const data = JSON.parse(body);
|
|
232
|
+
// Input validation
|
|
233
|
+
if (!data.phoneNumber || typeof data.phoneNumber !== 'string') {
|
|
234
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
235
|
+
res.end(JSON.stringify({ error: 'phoneNumber is required' }));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!data.goal || typeof data.goal !== 'string') {
|
|
239
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
240
|
+
res.end(JSON.stringify({ error: 'goal is required' }));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (data.phoneNumber.length > MAX_PHONE_LENGTH) {
|
|
244
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
245
|
+
res.end(JSON.stringify({ error: 'phoneNumber too long' }));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (data.goal.length > MAX_GOAL_LENGTH) {
|
|
249
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
250
|
+
res.end(JSON.stringify({ error: 'goal too long' }));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (data.context && data.context.length > MAX_CONTEXT_LENGTH) {
|
|
254
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
255
|
+
res.end(JSON.stringify({ error: 'context too long' }));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const callId = await this.initiateCallInternal(data.phoneNumber, data.goal, data.context);
|
|
259
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
260
|
+
res.end(JSON.stringify({ callId, status: 'initiating' }));
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
const message = err instanceof Error ? err.message : 'Internal server error';
|
|
264
|
+
const statusCode = message.toLowerCase().includes('preflight') ? 400 : 500;
|
|
265
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
266
|
+
res.end(JSON.stringify({ error: message }));
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Twilio voice webhook - returns TwiML for Media Streams
|
|
272
|
+
*/
|
|
273
|
+
handleTwilioVoice(req, res, url) {
|
|
274
|
+
let body = '';
|
|
275
|
+
let bodySize = 0;
|
|
276
|
+
req.on('data', (chunk) => {
|
|
277
|
+
bodySize += chunk.length;
|
|
278
|
+
if (bodySize > MAX_BODY_SIZE) {
|
|
279
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
280
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
281
|
+
req.destroy();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
body += chunk.toString();
|
|
285
|
+
});
|
|
286
|
+
req.on('end', () => {
|
|
287
|
+
// Validate Twilio webhook signature
|
|
288
|
+
const signature = req.headers['x-twilio-signature'];
|
|
289
|
+
const webhookUrl = `${this.options.publicUrl}${req.url}`;
|
|
290
|
+
const params = parseWebhookBody(body);
|
|
291
|
+
const callId = url.searchParams.get('callId');
|
|
292
|
+
this.log(`[Twilio] /voice webhook received callId=${callId ?? 'missing'} signature=${signature ? 'present' : 'missing'}`);
|
|
293
|
+
if (signature &&
|
|
294
|
+
!validateWebhookSignature(this.options.config, signature, webhookUrl, params)) {
|
|
295
|
+
this.warn('[Twilio] Invalid webhook signature');
|
|
296
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
297
|
+
res.end(JSON.stringify({ error: 'Invalid signature' }));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!callId || !this.sessions.has(callId)) {
|
|
301
|
+
if (!this.isPreflightCallId(callId)) {
|
|
302
|
+
this.warn(`[Twilio] /voice webhook has unknown callId=${callId ?? 'missing'}`);
|
|
303
|
+
}
|
|
304
|
+
res.writeHead(200, { 'Content-Type': 'application/xml' });
|
|
305
|
+
res.end(generateErrorTwiml('Sorry, this call cannot be connected. Please try again later.'));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
res.writeHead(200, { 'Content-Type': 'application/xml' });
|
|
309
|
+
res.end(generateMediaStreamsTwiml(this.options.config, callId));
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Twilio status callback
|
|
314
|
+
*/
|
|
315
|
+
handleTwilioStatus(req, res, url) {
|
|
316
|
+
let body = '';
|
|
317
|
+
let bodySize = 0;
|
|
318
|
+
req.on('data', (chunk) => {
|
|
319
|
+
bodySize += chunk.length;
|
|
320
|
+
if (bodySize > MAX_BODY_SIZE) {
|
|
321
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
322
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
323
|
+
req.destroy();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
body += chunk.toString();
|
|
327
|
+
});
|
|
328
|
+
req.on('end', () => {
|
|
329
|
+
// Validate Twilio webhook signature
|
|
330
|
+
const signature = req.headers['x-twilio-signature'];
|
|
331
|
+
const webhookUrl = `${this.options.publicUrl}${req.url}`;
|
|
332
|
+
const params = parseWebhookBody(body);
|
|
333
|
+
if (signature &&
|
|
334
|
+
!validateWebhookSignature(this.options.config, signature, webhookUrl, params)) {
|
|
335
|
+
this.warn('[Twilio] Invalid webhook signature');
|
|
336
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
337
|
+
res.end(JSON.stringify({ error: 'Invalid signature' }));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const callId = url.searchParams.get('callId');
|
|
341
|
+
const webhook = params;
|
|
342
|
+
const session = callId ? this.sessions.get(callId) : null;
|
|
343
|
+
const status = webhook.CallStatus;
|
|
344
|
+
this.log(`[Twilio] /status callback callId=${callId ?? 'missing'} status=${status ?? 'unknown'} callSid=${webhook.CallSid ?? 'unknown'}`);
|
|
345
|
+
if (session) {
|
|
346
|
+
switch (status) {
|
|
347
|
+
case 'ringing':
|
|
348
|
+
session.updateStatus('ringing');
|
|
349
|
+
this.broadcastToControl({ type: 'call_ringing', callId: session.callId });
|
|
350
|
+
break;
|
|
351
|
+
case 'in-progress':
|
|
352
|
+
// Keep status in sync for cases where media stream never starts.
|
|
353
|
+
session.updateStatus('in-progress');
|
|
354
|
+
break;
|
|
355
|
+
case 'completed':
|
|
356
|
+
case 'busy':
|
|
357
|
+
case 'failed':
|
|
358
|
+
case 'no-answer':
|
|
359
|
+
case 'canceled':
|
|
360
|
+
session.endFromProviderStatus(status);
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else if (callId) {
|
|
365
|
+
if (!this.isPreflightCallId(callId)) {
|
|
366
|
+
this.warn(`[Twilio] /status callback for unknown callId=${callId}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (status && TERMINAL_CALL_STATUSES.has(status) && !session && !this.isPreflightCallId(callId)) {
|
|
370
|
+
this.warn(`[Twilio] Terminal status received without active session: ${status}`);
|
|
371
|
+
}
|
|
372
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
373
|
+
res.end(JSON.stringify({ received: true }));
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Handle control WebSocket connection
|
|
378
|
+
*/
|
|
379
|
+
handleControlConnection(ws) {
|
|
380
|
+
this.log('[Control] Client connected');
|
|
381
|
+
this.controlClients.add(ws);
|
|
382
|
+
ws.on('message', async (data) => {
|
|
383
|
+
try {
|
|
384
|
+
const msg = JSON.parse(data.toString());
|
|
385
|
+
await this.handleControlMessage(ws, msg);
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
ws.send(JSON.stringify({
|
|
389
|
+
type: 'error',
|
|
390
|
+
message: err instanceof Error ? err.message : 'Invalid message',
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
ws.on('close', () => {
|
|
395
|
+
this.log('[Control] Client disconnected');
|
|
396
|
+
this.controlClients.delete(ws);
|
|
397
|
+
});
|
|
398
|
+
ws.on('error', (err) => {
|
|
399
|
+
this.error('[Control] Error:', err);
|
|
400
|
+
this.controlClients.delete(ws);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Handle control messages from clients
|
|
405
|
+
*/
|
|
406
|
+
async handleControlMessage(ws, msg) {
|
|
407
|
+
switch (msg.type) {
|
|
408
|
+
case 'initiate_call': {
|
|
409
|
+
// initiateCallInternal broadcasts call_started to all control clients,
|
|
410
|
+
// so we don't need to send it directly to avoid duplicate events
|
|
411
|
+
await this.initiateCallInternal(msg.phoneNumber, msg.goal, msg.context);
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
case 'speak': {
|
|
415
|
+
const session = this.sessions.get(msg.callId);
|
|
416
|
+
if (session) {
|
|
417
|
+
await session.speak(msg.text);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
ws.send(JSON.stringify({
|
|
421
|
+
type: 'error',
|
|
422
|
+
callId: msg.callId,
|
|
423
|
+
message: 'Call not found',
|
|
424
|
+
}));
|
|
425
|
+
}
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
case 'hangup': {
|
|
429
|
+
const session = this.sessions.get(msg.callId);
|
|
430
|
+
if (session) {
|
|
431
|
+
await session.hangup();
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Handle media stream WebSocket connection
|
|
439
|
+
* Twilio sends callId in the 'start' event's customParameters, not in the URL
|
|
440
|
+
*/
|
|
441
|
+
handleMediaStreamConnection(ws) {
|
|
442
|
+
this.log('[Media] Stream WebSocket connected, waiting for start event...');
|
|
443
|
+
this.log(`[Media] Active sessions: ${[...this.sessions.keys()].join(', ')}`);
|
|
444
|
+
let sessionInitialized = false;
|
|
445
|
+
ws.on('message', (data) => {
|
|
446
|
+
try {
|
|
447
|
+
const msg = JSON.parse(data.toString());
|
|
448
|
+
// Handle the 'start' event to get callId from customParameters
|
|
449
|
+
if (msg.event === 'start' && !sessionInitialized) {
|
|
450
|
+
const callId = msg.start?.customParameters?.callId;
|
|
451
|
+
this.log(`[Media] Received start event, callId: ${callId}`);
|
|
452
|
+
if (!callId) {
|
|
453
|
+
this.error('[Media] No callId in start event customParameters');
|
|
454
|
+
ws.close(1008, 'Missing callId');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const session = this.sessions.get(callId);
|
|
458
|
+
if (!session) {
|
|
459
|
+
this.error(`[Media] No session found for call ${callId}`);
|
|
460
|
+
ws.close(1008, 'Call not found');
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
sessionInitialized = true;
|
|
464
|
+
this.log('[Media] Found session, initializing media stream...');
|
|
465
|
+
session
|
|
466
|
+
.initializeMediaStream(ws, msg)
|
|
467
|
+
.then(() => {
|
|
468
|
+
this.log('[Media] Media stream initialized successfully');
|
|
469
|
+
})
|
|
470
|
+
.catch((err) => {
|
|
471
|
+
this.error('[Media] Failed to initialize:', err);
|
|
472
|
+
// Clean up session on initialization failure
|
|
473
|
+
this.sessions.delete(callId);
|
|
474
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
475
|
+
ws.close(1011, 'Failed to initialize');
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
this.error('[Media] Error parsing message:', err);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
ws.on('close', () => {
|
|
484
|
+
this.log('[Media] WebSocket closed');
|
|
485
|
+
});
|
|
486
|
+
ws.on('error', (err) => {
|
|
487
|
+
this.error('[Media] WebSocket error:', err);
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Internal method to initiate a call
|
|
492
|
+
*/
|
|
493
|
+
async initiateCallInternal(phoneNumber, goal, context) {
|
|
494
|
+
const [ffmpegPreflight, twilioPreflight, deepgramPreflight, elevenLabsPreflight] = await Promise.all([
|
|
495
|
+
preflightFfmpeg(),
|
|
496
|
+
preflightTwilioCallSetup(this.options.config),
|
|
497
|
+
preflightDeepgramSTT(this.options.config.deepgramApiKey),
|
|
498
|
+
preflightElevenLabsTTSBudget(this.options.config.elevenLabsApiKey, goal, context),
|
|
499
|
+
]);
|
|
500
|
+
const failedPreflight = [ffmpegPreflight, twilioPreflight, deepgramPreflight, elevenLabsPreflight].find((result) => !result.ok);
|
|
501
|
+
if (failedPreflight) {
|
|
502
|
+
throw new Error(failedPreflight.message);
|
|
503
|
+
}
|
|
504
|
+
this.log(`[Preflight] ${ffmpegPreflight.message}`);
|
|
505
|
+
this.log(`[Preflight] ${twilioPreflight.message}`);
|
|
506
|
+
this.log(`[Preflight] ${deepgramPreflight.message}`);
|
|
507
|
+
this.log(`[Preflight] ${elevenLabsPreflight.message}`);
|
|
508
|
+
const publicWebhookPreflight = await this.preflightPublicWebhook();
|
|
509
|
+
if (!publicWebhookPreflight.ok) {
|
|
510
|
+
throw new Error(publicWebhookPreflight.message);
|
|
511
|
+
}
|
|
512
|
+
this.log(`[Preflight] ${publicWebhookPreflight.message}`);
|
|
513
|
+
const callId = randomUUID();
|
|
514
|
+
const formattedNumber = formatPhoneNumber(phoneNumber);
|
|
515
|
+
// Create session
|
|
516
|
+
const session = new CallSession(callId, this.options.config, formattedNumber, goal, context);
|
|
517
|
+
// Forward session events to control clients
|
|
518
|
+
session.on('message', (msg) => {
|
|
519
|
+
this.broadcastToControl(msg);
|
|
520
|
+
});
|
|
521
|
+
session.on('ended', (state) => {
|
|
522
|
+
this.sessions.delete(callId);
|
|
523
|
+
this.emit('call_ended', callId, state);
|
|
524
|
+
});
|
|
525
|
+
this.sessions.set(callId, session);
|
|
526
|
+
// Initiate call via Twilio
|
|
527
|
+
try {
|
|
528
|
+
const result = await initiateCall(this.options.config, formattedNumber, callId);
|
|
529
|
+
session.setCallSid(result.callSid);
|
|
530
|
+
this.emit('call_started', callId);
|
|
531
|
+
this.broadcastToControl({
|
|
532
|
+
type: 'call_started',
|
|
533
|
+
callId,
|
|
534
|
+
callSid: result.callSid,
|
|
535
|
+
});
|
|
536
|
+
return callId;
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
this.sessions.delete(callId);
|
|
540
|
+
throw err;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Broadcast message to all control clients
|
|
545
|
+
*/
|
|
546
|
+
broadcastToControl(msg) {
|
|
547
|
+
const data = JSON.stringify(msg);
|
|
548
|
+
for (const client of this.controlClients) {
|
|
549
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
550
|
+
client.send(data);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Get a session by call ID
|
|
556
|
+
*/
|
|
557
|
+
getSession(callId) {
|
|
558
|
+
return this.sessions.get(callId);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Get all active sessions
|
|
562
|
+
*/
|
|
563
|
+
getActiveSessions() {
|
|
564
|
+
return new Map(this.sessions);
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Check if server is running
|
|
568
|
+
*/
|
|
569
|
+
get isRunning() {
|
|
570
|
+
return this.server !== null;
|
|
571
|
+
}
|
|
572
|
+
startStatusReconcileLoop() {
|
|
573
|
+
if (this.statusReconcileTimer) {
|
|
574
|
+
clearInterval(this.statusReconcileTimer);
|
|
575
|
+
}
|
|
576
|
+
this.statusReconcileTimer = setInterval(() => {
|
|
577
|
+
this.reconcileStatusesWithProvider().catch((err) => {
|
|
578
|
+
this.warn(`[Twilio] Status reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
579
|
+
});
|
|
580
|
+
}, STATUS_RECONCILE_INTERVAL_MS);
|
|
581
|
+
}
|
|
582
|
+
async reconcileStatusesWithProvider() {
|
|
583
|
+
if (this.sessions.size === 0)
|
|
584
|
+
return;
|
|
585
|
+
for (const session of this.sessions.values()) {
|
|
586
|
+
const state = session.getState();
|
|
587
|
+
const callSid = state.callSid;
|
|
588
|
+
if (!callSid)
|
|
589
|
+
continue;
|
|
590
|
+
if (TERMINAL_CALL_STATUSES.has(state.status))
|
|
591
|
+
continue;
|
|
592
|
+
const providerStatus = await getCallStatus(this.options.config, callSid);
|
|
593
|
+
const normalized = providerStatus;
|
|
594
|
+
if (normalized === 'ringing' && state.status !== 'ringing') {
|
|
595
|
+
session.updateStatus('ringing');
|
|
596
|
+
this.broadcastToControl({ type: 'call_ringing', callId: state.callId });
|
|
597
|
+
}
|
|
598
|
+
else if (normalized === 'in-progress' && state.status !== 'in-progress') {
|
|
599
|
+
session.updateStatus('in-progress');
|
|
600
|
+
}
|
|
601
|
+
else if (normalized === 'completed' || normalized === 'busy' || normalized === 'failed' || normalized === 'no-answer' || normalized === 'canceled') {
|
|
602
|
+
const terminalStatus = normalized;
|
|
603
|
+
this.log(`[Twilio] Reconciled terminal status callId=${state.callId} status=${terminalStatus}`);
|
|
604
|
+
session.endFromProviderStatus(terminalStatus);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
async preflightPublicWebhook() {
|
|
609
|
+
const publicUrl = this.options.publicUrl.replace(/\/+$/, '');
|
|
610
|
+
if (!publicUrl.startsWith('https://') && !publicUrl.startsWith('http://')) {
|
|
611
|
+
return {
|
|
612
|
+
ok: false,
|
|
613
|
+
message: `Public webhook preflight failed: invalid publicUrl "${this.options.publicUrl}".`,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
const controller = new AbortController();
|
|
617
|
+
const timeout = setTimeout(() => controller.abort(), PUBLIC_WEBHOOK_PREFLIGHT_TIMEOUT_MS);
|
|
618
|
+
const preflightCallId = `preflight-${randomUUID().slice(0, 8)}`;
|
|
619
|
+
try {
|
|
620
|
+
const healthResponse = await fetch(`${publicUrl}/health`, {
|
|
621
|
+
method: 'GET',
|
|
622
|
+
signal: controller.signal,
|
|
623
|
+
});
|
|
624
|
+
if (!healthResponse.ok) {
|
|
625
|
+
return {
|
|
626
|
+
ok: false,
|
|
627
|
+
message: `Public webhook preflight failed: ${publicUrl}/health returned HTTP ${healthResponse.status}.`,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const voiceResponse = await fetch(`${publicUrl}/twilio/voice?callId=${encodeURIComponent(preflightCallId)}`, {
|
|
631
|
+
method: 'POST',
|
|
632
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
633
|
+
body: 'CallSid=CApreflight&CallStatus=ringing',
|
|
634
|
+
signal: controller.signal,
|
|
635
|
+
});
|
|
636
|
+
if (!voiceResponse.ok) {
|
|
637
|
+
return {
|
|
638
|
+
ok: false,
|
|
639
|
+
message: `Public webhook preflight failed: ${publicUrl}/twilio/voice returned HTTP ${voiceResponse.status}.`,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
const statusResponse = await fetch(`${publicUrl}/twilio/status?callId=${encodeURIComponent(preflightCallId)}`, {
|
|
643
|
+
method: 'POST',
|
|
644
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
645
|
+
body: 'CallSid=CApreflight&CallStatus=ringing',
|
|
646
|
+
signal: controller.signal,
|
|
647
|
+
});
|
|
648
|
+
if (!statusResponse.ok) {
|
|
649
|
+
return {
|
|
650
|
+
ok: false,
|
|
651
|
+
message: `Public webhook preflight failed: ${publicUrl}/twilio/status returned HTTP ${statusResponse.status}.`,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
ok: true,
|
|
656
|
+
message: `Public webhook preflight passed: ${publicUrl} is reachable for Twilio voice and status callbacks.`,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
catch (err) {
|
|
660
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
661
|
+
return {
|
|
662
|
+
ok: false,
|
|
663
|
+
message: `Public webhook preflight failed: could not reach ${publicUrl} (${detail}).`,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
clearTimeout(timeout);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Create and configure a call server
|
|
673
|
+
*/
|
|
674
|
+
export function createCallServer(config, port, publicUrl) {
|
|
675
|
+
return new CallServer({
|
|
676
|
+
port,
|
|
677
|
+
publicUrl,
|
|
678
|
+
config,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
//# sourceMappingURL=call-server.js.map
|