Update app.js
Browse files
app.js
CHANGED
|
@@ -32,7 +32,10 @@ const sendTelnyxCmd = async (callId, action, body = {}) => {
|
|
| 32 |
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
|
| 33 |
body: JSON.stringify(body)
|
| 34 |
});
|
| 35 |
-
if (!response.ok)
|
|
|
|
|
|
|
|
|
|
| 36 |
return response;
|
| 37 |
} catch (e) {
|
| 38 |
log("TELNYX CMD NETWORK ERROR", e.message);
|
|
@@ -45,6 +48,12 @@ const updateLog = (callId, statusUpdates) => {
|
|
| 45 |
return entry;
|
| 46 |
};
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
// βββ VAD MATH UTILITIES βββ
|
| 49 |
const MU_LAW_TABLE = new Int16Array(256);
|
| 50 |
for (let i = 0; i < 256; i++) {
|
|
@@ -181,7 +190,9 @@ app.get("/", (req, res) => {
|
|
| 181 |
<button onClick={() => hangup(log.call_control_id)} className="bg-red-600 hover:bg-red-500 text-xs px-3 py-1 rounded font-bold transition">
|
| 182 |
Force Hang Up
|
| 183 |
</button>
|
| 184 |
-
) :
|
|
|
|
|
|
|
| 185 |
</td>
|
| 186 |
<td className="p-4">
|
| 187 |
{log.recording_url ?
|
|
@@ -208,7 +219,7 @@ app.get("/", (req, res) => {
|
|
| 208 |
app.get("/api/logs", (req, res) => res.json([...callLogs].reverse()));
|
| 209 |
app.get("/health", (req, res) => res.json({ status: "ok", uptime: process.uptime() }));
|
| 210 |
|
| 211 |
-
// βββ PROXY FOR TELNYX RECORDINGS βββββββββββββ
|
| 212 |
app.get("/play-recording/:call_control_id", async (req, res) => {
|
| 213 |
const logEntry = callLogs.find(l => l.call_control_id === req.params.call_control_id);
|
| 214 |
if (!logEntry || !logEntry.raw_telnyx_url) return res.sendStatus(404);
|
|
@@ -219,22 +230,28 @@ app.get("/play-recording/:call_control_id", async (req, res) => {
|
|
| 219 |
res.setHeader("Content-Type", "audio/mpeg");
|
| 220 |
res.setHeader("Content-Length", buffer.length);
|
| 221 |
res.send(buffer);
|
| 222 |
-
} catch (e) {
|
|
|
|
|
|
|
| 223 |
});
|
| 224 |
|
| 225 |
-
// βββ DIAL OUT ROUTE ββββββββββββββββββββββββββ
|
| 226 |
app.post("/dial", async (req, res) => {
|
| 227 |
const { to } = req.body;
|
| 228 |
const voice = "Algieba";
|
| 229 |
log("DIAL", `Dialing ${to}...`);
|
| 230 |
|
| 231 |
try {
|
| 232 |
-
const response = await
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
const data = await response.json();
|
| 240 |
if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
|
|
@@ -252,19 +269,27 @@ app.post("/dial", async (req, res) => {
|
|
| 252 |
timers: {},
|
| 253 |
created_at: new Date().toISOString()
|
| 254 |
});
|
|
|
|
| 255 |
res.json({ ok: true });
|
| 256 |
-
} catch (err) {
|
|
|
|
|
|
|
| 257 |
});
|
| 258 |
|
| 259 |
-
// βββ MANUAL HANG UP ββββββββββββββββββββββββββ
|
| 260 |
app.post("/call/hangup", async (req, res) => {
|
| 261 |
const { call_control_id } = req.body;
|
| 262 |
if (!call_control_id) return res.status(400).json({ error: "Missing call_control_id" });
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
});
|
| 266 |
|
| 267 |
-
// βββ TELNYX WEBHOOK (State Only, No STT) ββββββ
|
| 268 |
app.post("/webhook/call", async (req, res) => {
|
| 269 |
const type = req.body?.data?.event_type;
|
| 270 |
const payload = req.body?.data?.payload;
|
|
@@ -273,6 +298,8 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 273 |
if (!type) return res.sendStatus(200);
|
| 274 |
|
| 275 |
const entry = callLogs.find(l => l.call_control_id === callId);
|
|
|
|
|
|
|
| 276 |
if (entry && entry.status === "completed" && type !== "call.recording.saved") {
|
| 277 |
return res.sendStatus(200);
|
| 278 |
}
|
|
@@ -300,9 +327,19 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 300 |
log("ANSWERED", `Call ${callId} connected.`);
|
| 301 |
updateLog(callId, { status: "in-progress" });
|
| 302 |
|
|
|
|
| 303 |
await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
|
| 304 |
|
| 305 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
const wsUrl = `wss://${req.headers.host}/audio-stream`;
|
| 307 |
await sendTelnyxCmd(callId, "stream_start", { stream_url: wsUrl, track: "inbound_track" });
|
| 308 |
|
|
@@ -321,19 +358,19 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 321 |
break;
|
| 322 |
|
| 323 |
case "call.recording.saved":
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
| 325 |
updateLog(callId, {
|
| 326 |
-
raw_telnyx_url:
|
| 327 |
recording_url: `/play-recording/${callId}`
|
| 328 |
});
|
| 329 |
break;
|
| 330 |
|
| 331 |
case "call.hangup":
|
| 332 |
updateLog(callId, { status: "completed", is_playing: false });
|
| 333 |
-
|
| 334 |
-
Object.values(entry.timers).forEach(clearTimeout);
|
| 335 |
-
entry.timers = {};
|
| 336 |
-
}
|
| 337 |
log("CALL ENDED", `Terminated.`);
|
| 338 |
break;
|
| 339 |
}
|
|
@@ -387,7 +424,7 @@ wss.on("connection", (ws, req) => {
|
|
| 387 |
|
| 388 |
// ββ 1. NOISE DETECTED (Human is speaking) ββ
|
| 389 |
if (rms > VOLUME_THRESHOLD) {
|
| 390 |
-
hasGreeted = true;
|
| 391 |
silenceFrames = 0;
|
| 392 |
pcmChunks.push(pcm16);
|
| 393 |
|
|
@@ -421,14 +458,13 @@ wss.on("connection", (ws, req) => {
|
|
| 421 |
const entry = callLogs.find(l => l.call_control_id === callControlId);
|
| 422 |
const voice = entry ? entry.voice_used : "Algieba";
|
| 423 |
|
| 424 |
-
// Compile WAV buffer for Groq
|
| 425 |
const totalLength = pcmChunks.reduce((acc, val) => acc + val.length, 0);
|
| 426 |
const finalPcm = new Int16Array(totalLength);
|
| 427 |
let offset = 0;
|
| 428 |
for (let chunk of pcmChunks) { finalPcm.set(chunk, offset); offset += chunk.length; }
|
| 429 |
pcmChunks = []; // Clear for next turn
|
| 430 |
|
| 431 |
-
//
|
| 432 |
if (totalLength < 8000 * 0.2) {
|
| 433 |
isProcessingTurn = false;
|
| 434 |
return;
|
|
@@ -456,9 +492,9 @@ wss.on("connection", (ws, req) => {
|
|
| 456 |
const aiData = await aiRes.json();
|
| 457 |
if (aiData.transcript) updateLog(callControlId, { last_transcript: aiData.transcript });
|
| 458 |
|
| 459 |
-
// Check race condition: Did human interrupt the AI thinking?
|
| 460 |
if (!isSpeaking && aiData.speak_url) {
|
| 461 |
-
log("AI_REPLY", `Playing
|
| 462 |
if (entry) entry.is_playing = true;
|
| 463 |
await sendTelnyxCmd(callControlId, "playback_start", {
|
| 464 |
audio_url: aiData.speak_url, target_legs: "self"
|
|
@@ -475,59 +511,6 @@ wss.on("connection", (ws, req) => {
|
|
| 475 |
});
|
| 476 |
});
|
| 477 |
|
| 478 |
-
// Custom fetch wrapper for internal call initiation without full endpoint
|
| 479 |
-
const originalFetch = global.fetch;
|
| 480 |
-
global.fetch = async (url, options) => {
|
| 481 |
-
if (url === "https://api.telnyx.com/v2/calls" && options?.method === "POST" && options?.isCustomDial) {
|
| 482 |
-
const rawUrl = url;
|
| 483 |
-
delete options.isCustomDial;
|
| 484 |
-
return originalFetch(rawUrl, options);
|
| 485 |
-
}
|
| 486 |
-
return originalFetch(url, options);
|
| 487 |
-
};
|
| 488 |
-
|
| 489 |
-
// Replace Dial API implementation to use standard fetch safely
|
| 490 |
-
app.post("/dial", async (req, res) => {
|
| 491 |
-
const { to } = req.body;
|
| 492 |
-
const voice = "Algieba";
|
| 493 |
-
log("DIAL", `Dialing ${to}...`);
|
| 494 |
-
|
| 495 |
-
try {
|
| 496 |
-
const response = await fetch("https://api.telnyx.com/v2/calls", {
|
| 497 |
-
method: "POST",
|
| 498 |
-
isCustomDial: true,
|
| 499 |
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
|
| 500 |
-
body: JSON.stringify({
|
| 501 |
-
connection_id: TELNYX_CONNECTION_ID,
|
| 502 |
-
to,
|
| 503 |
-
from: TELNYX_FROM_NUMBER,
|
| 504 |
-
custom_headers: [{ name: "X-Voice-Selection", value: voice }]
|
| 505 |
-
}),
|
| 506 |
-
});
|
| 507 |
-
|
| 508 |
-
const data = await response.json();
|
| 509 |
-
if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
|
| 510 |
-
|
| 511 |
-
callLogs.push({
|
| 512 |
-
id: randomUUID(),
|
| 513 |
-
call_control_id: data.data.call_control_id,
|
| 514 |
-
target_number: to,
|
| 515 |
-
voice_used: voice,
|
| 516 |
-
status: "dialing",
|
| 517 |
-
last_transcript: "",
|
| 518 |
-
recording_url: null,
|
| 519 |
-
raw_telnyx_url: null,
|
| 520 |
-
is_playing: false,
|
| 521 |
-
timers: {},
|
| 522 |
-
created_at: new Date().toISOString()
|
| 523 |
-
});
|
| 524 |
-
|
| 525 |
-
res.json({ ok: true });
|
| 526 |
-
} catch (err) {
|
| 527 |
-
res.status(500).json({ ok: false, error: err.message });
|
| 528 |
-
}
|
| 529 |
-
});
|
| 530 |
-
|
| 531 |
const requiredEnv = ["TELNYX_API_KEY", "TELNYX_CONNECTION_ID", "TELNYX_FROM_NUMBER", "AI_SERVER_URL"];
|
| 532 |
requiredEnv.forEach((key) => {
|
| 533 |
if (!process.env[key]) log("STARTUP WARNING", `Missing env var: ${key}`);
|
|
|
|
| 32 |
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
|
| 33 |
body: JSON.stringify(body)
|
| 34 |
});
|
| 35 |
+
if (!response.ok) {
|
| 36 |
+
const errText = await response.text();
|
| 37 |
+
log("TELNYX CMD ERROR", `[${action}] failed (${response.status}): ${errText}`);
|
| 38 |
+
}
|
| 39 |
return response;
|
| 40 |
} catch (e) {
|
| 41 |
log("TELNYX CMD NETWORK ERROR", e.message);
|
|
|
|
| 48 |
return entry;
|
| 49 |
};
|
| 50 |
|
| 51 |
+
const clearCallTimers = (entry) => {
|
| 52 |
+
if (!entry || !entry.timers) return;
|
| 53 |
+
Object.values(entry.timers).forEach(clearTimeout);
|
| 54 |
+
entry.timers = {};
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
// βββ VAD MATH UTILITIES βββ
|
| 58 |
const MU_LAW_TABLE = new Int16Array(256);
|
| 59 |
for (let i = 0; i < 256; i++) {
|
|
|
|
| 190 |
<button onClick={() => hangup(log.call_control_id)} className="bg-red-600 hover:bg-red-500 text-xs px-3 py-1 rounded font-bold transition">
|
| 191 |
Force Hang Up
|
| 192 |
</button>
|
| 193 |
+
) : (
|
| 194 |
+
<span className="text-gray-500 text-xs">Ended</span>
|
| 195 |
+
)}
|
| 196 |
</td>
|
| 197 |
<td className="p-4">
|
| 198 |
{log.recording_url ?
|
|
|
|
| 219 |
app.get("/api/logs", (req, res) => res.json([...callLogs].reverse()));
|
| 220 |
app.get("/health", (req, res) => res.json({ status: "ok", uptime: process.uptime() }));
|
| 221 |
|
| 222 |
+
// βββ PROXY FOR TELNYX RECORDINGS βββββββββββββββββββββββββββ
|
| 223 |
app.get("/play-recording/:call_control_id", async (req, res) => {
|
| 224 |
const logEntry = callLogs.find(l => l.call_control_id === req.params.call_control_id);
|
| 225 |
if (!logEntry || !logEntry.raw_telnyx_url) return res.sendStatus(404);
|
|
|
|
| 230 |
res.setHeader("Content-Type", "audio/mpeg");
|
| 231 |
res.setHeader("Content-Length", buffer.length);
|
| 232 |
res.send(buffer);
|
| 233 |
+
} catch (e) {
|
| 234 |
+
res.sendStatus(500);
|
| 235 |
+
}
|
| 236 |
});
|
| 237 |
|
| 238 |
+
// βββ DIAL OUT ROUTE ββββββββββββββββββββββββββββββββββββββββ
|
| 239 |
app.post("/dial", async (req, res) => {
|
| 240 |
const { to } = req.body;
|
| 241 |
const voice = "Algieba";
|
| 242 |
log("DIAL", `Dialing ${to}...`);
|
| 243 |
|
| 244 |
try {
|
| 245 |
+
const response = await fetch("https://api.telnyx.com/v2/calls", {
|
| 246 |
+
method: "POST",
|
| 247 |
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
|
| 248 |
+
body: JSON.stringify({
|
| 249 |
+
connection_id: TELNYX_CONNECTION_ID,
|
| 250 |
+
to,
|
| 251 |
+
from: TELNYX_FROM_NUMBER,
|
| 252 |
+
custom_headers: [{ name: "X-Voice-Selection", value: voice }]
|
| 253 |
+
}),
|
| 254 |
+
});
|
| 255 |
|
| 256 |
const data = await response.json();
|
| 257 |
if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
|
|
|
|
| 269 |
timers: {},
|
| 270 |
created_at: new Date().toISOString()
|
| 271 |
});
|
| 272 |
+
|
| 273 |
res.json({ ok: true });
|
| 274 |
+
} catch (err) {
|
| 275 |
+
res.status(500).json({ ok: false, error: err.message });
|
| 276 |
+
}
|
| 277 |
});
|
| 278 |
|
| 279 |
+
// βββ MANUAL HANG UP ββββββββββββββββββββββββββββββββββββββββ
|
| 280 |
app.post("/call/hangup", async (req, res) => {
|
| 281 |
const { call_control_id } = req.body;
|
| 282 |
if (!call_control_id) return res.status(400).json({ error: "Missing call_control_id" });
|
| 283 |
+
|
| 284 |
+
try {
|
| 285 |
+
await sendTelnyxCmd(call_control_id, "hangup");
|
| 286 |
+
res.json({ ok: true });
|
| 287 |
+
} catch (err) {
|
| 288 |
+
res.status(500).json({ ok: false, error: err.message });
|
| 289 |
+
}
|
| 290 |
});
|
| 291 |
|
| 292 |
+
// βββ TELNYX WEBHOOK (State Only, No STT) βββββββββββββββββββ
|
| 293 |
app.post("/webhook/call", async (req, res) => {
|
| 294 |
const type = req.body?.data?.event_type;
|
| 295 |
const payload = req.body?.data?.payload;
|
|
|
|
| 298 |
if (!type) return res.sendStatus(200);
|
| 299 |
|
| 300 |
const entry = callLogs.find(l => l.call_control_id === callId);
|
| 301 |
+
|
| 302 |
+
// Local Lock: Ignore events for dead calls to prevent 422 errors
|
| 303 |
if (entry && entry.status === "completed" && type !== "call.recording.saved") {
|
| 304 |
return res.sendStatus(200);
|
| 305 |
}
|
|
|
|
| 327 |
log("ANSWERED", `Call ${callId} connected.`);
|
| 328 |
updateLog(callId, { status: "in-progress" });
|
| 329 |
|
| 330 |
+
// Start Dual-Channel Recording
|
| 331 |
await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
|
| 332 |
|
| 333 |
+
// DEACTIVATED UNTIL YOU FIX AUDIO LEVELS:
|
| 334 |
+
/*
|
| 335 |
+
await sendTelnyxCmd(callId, "playback_start", {
|
| 336 |
+
audio_url: `https://${req.headers.host}/ambience.mp3`,
|
| 337 |
+
loop: "infinity",
|
| 338 |
+
target_legs: "self"
|
| 339 |
+
});
|
| 340 |
+
*/
|
| 341 |
+
|
| 342 |
+
// Start WebSocket Media Stream to listen to the caller natively via VAD
|
| 343 |
const wsUrl = `wss://${req.headers.host}/audio-stream`;
|
| 344 |
await sendTelnyxCmd(callId, "stream_start", { stream_url: wsUrl, track: "inbound_track" });
|
| 345 |
|
|
|
|
| 358 |
break;
|
| 359 |
|
| 360 |
case "call.recording.saved":
|
| 361 |
+
// THE PROMINENT RECORDING LOG YOU REQUESTED
|
| 362 |
+
const rawUrl = payload.recording_urls.mp3;
|
| 363 |
+
log("RECORDING URL", `\n\nβ¬οΈ DOWNLOAD RAW AUDIO HERE β¬οΈ\n${rawUrl}\n\n`);
|
| 364 |
+
|
| 365 |
updateLog(callId, {
|
| 366 |
+
raw_telnyx_url: rawUrl,
|
| 367 |
recording_url: `/play-recording/${callId}`
|
| 368 |
});
|
| 369 |
break;
|
| 370 |
|
| 371 |
case "call.hangup":
|
| 372 |
updateLog(callId, { status: "completed", is_playing: false });
|
| 373 |
+
clearCallTimers(entry);
|
|
|
|
|
|
|
|
|
|
| 374 |
log("CALL ENDED", `Terminated.`);
|
| 375 |
break;
|
| 376 |
}
|
|
|
|
| 424 |
|
| 425 |
// ββ 1. NOISE DETECTED (Human is speaking) ββ
|
| 426 |
if (rms > VOLUME_THRESHOLD) {
|
| 427 |
+
hasGreeted = true;
|
| 428 |
silenceFrames = 0;
|
| 429 |
pcmChunks.push(pcm16);
|
| 430 |
|
|
|
|
| 458 |
const entry = callLogs.find(l => l.call_control_id === callControlId);
|
| 459 |
const voice = entry ? entry.voice_used : "Algieba";
|
| 460 |
|
|
|
|
| 461 |
const totalLength = pcmChunks.reduce((acc, val) => acc + val.length, 0);
|
| 462 |
const finalPcm = new Int16Array(totalLength);
|
| 463 |
let offset = 0;
|
| 464 |
for (let chunk of pcmChunks) { finalPcm.set(chunk, offset); offset += chunk.length; }
|
| 465 |
pcmChunks = []; // Clear for next turn
|
| 466 |
|
| 467 |
+
// Ignore tiny noise bursts (coughs, mic bumps)
|
| 468 |
if (totalLength < 8000 * 0.2) {
|
| 469 |
isProcessingTurn = false;
|
| 470 |
return;
|
|
|
|
| 492 |
const aiData = await aiRes.json();
|
| 493 |
if (aiData.transcript) updateLog(callControlId, { last_transcript: aiData.transcript });
|
| 494 |
|
| 495 |
+
// Check race condition: Did human interrupt the AI while it was thinking?
|
| 496 |
if (!isSpeaking && aiData.speak_url) {
|
| 497 |
+
log("AI_REPLY", `Playing full reply.`);
|
| 498 |
if (entry) entry.is_playing = true;
|
| 499 |
await sendTelnyxCmd(callControlId, "playback_start", {
|
| 500 |
audio_url: aiData.speak_url, target_legs: "self"
|
|
|
|
| 511 |
});
|
| 512 |
});
|
| 513 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
const requiredEnv = ["TELNYX_API_KEY", "TELNYX_CONNECTION_ID", "TELNYX_FROM_NUMBER", "AI_SERVER_URL"];
|
| 515 |
requiredEnv.forEach((key) => {
|
| 516 |
if (!process.env[key]) log("STARTUP WARNING", `Missing env var: ${key}`);
|