Update app.js
Browse files
app.js
CHANGED
|
@@ -24,6 +24,68 @@ const log = (tag, ...args) => {
|
|
| 24 |
|
| 25 |
let callLogs = [];
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
// βββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
// 1. EMBEDDED DASHBOARD
|
| 29 |
// βββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -34,7 +96,7 @@ app.get("/", (req, res) => {
|
|
| 34 |
<head>
|
| 35 |
<meta charset="UTF-8">
|
| 36 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 37 |
-
<title>Telecom Control Panel</title>
|
| 38 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 39 |
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
|
| 40 |
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
|
|
@@ -96,13 +158,16 @@ app.get("/", (req, res) => {
|
|
| 96 |
<div className="bg-gray-800 rounded-lg overflow-hidden shadow-lg">
|
| 97 |
<table className="w-full text-left">
|
| 98 |
<thead className="bg-gray-900 text-gray-400 text-sm">
|
| 99 |
-
<tr><th className="p-4">Call ID</th><th className="p-4">Number</th><th className="p-4">Status</th><th className="p-4">Action</th><th className="p-4">Recording</th></tr>
|
| 100 |
</thead>
|
| 101 |
<tbody className="divide-y divide-gray-700">
|
| 102 |
{logs.map(log => (
|
| 103 |
<tr key={log.id}>
|
| 104 |
<td className="p-4 text-xs font-mono text-gray-500">{log.call_control_id.slice(0,8)}...</td>
|
| 105 |
<td className="p-4 font-mono">{log.target_number}</td>
|
|
|
|
|
|
|
|
|
|
| 106 |
<td className="p-4">
|
| 107 |
<div className="flex flex-col gap-1">
|
| 108 |
<span className={\`inline-block w-fit px-2 py-1 rounded text-xs \${log.status === 'completed' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'}\`}>
|
|
@@ -116,9 +181,7 @@ app.get("/", (req, res) => {
|
|
| 116 |
<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">
|
| 117 |
Force Hang Up
|
| 118 |
</button>
|
| 119 |
-
) :
|
| 120 |
-
<span className="text-gray-500 text-xs">Ended</span>
|
| 121 |
-
)}
|
| 122 |
</td>
|
| 123 |
<td className="p-4">
|
| 124 |
{log.recording_url ?
|
|
@@ -128,7 +191,7 @@ app.get("/", (req, res) => {
|
|
| 128 |
</td>
|
| 129 |
</tr>
|
| 130 |
))}
|
| 131 |
-
{logs.length === 0 && <tr><td colSpan="
|
| 132 |
</tbody>
|
| 133 |
</table>
|
| 134 |
</div>
|
|
@@ -142,16 +205,13 @@ app.get("/", (req, res) => {
|
|
| 142 |
`);
|
| 143 |
});
|
| 144 |
|
| 145 |
-
app.get("/api/logs", (req, res) =>
|
| 146 |
-
res.json([...callLogs].reverse());
|
| 147 |
-
});
|
| 148 |
-
|
| 149 |
app.get("/health", (req, res) => res.json({ status: "ok", uptime: process.uptime() }));
|
| 150 |
|
|
|
|
| 151 |
app.get("/play-recording/:call_control_id", async (req, res) => {
|
| 152 |
const logEntry = callLogs.find(l => l.call_control_id === req.params.call_control_id);
|
| 153 |
if (!logEntry || !logEntry.raw_telnyx_url) return res.sendStatus(404);
|
| 154 |
-
|
| 155 |
try {
|
| 156 |
const s3Res = await fetch(logEntry.raw_telnyx_url);
|
| 157 |
if (!s3Res.ok) return res.sendStatus(s3Res.status);
|
|
@@ -159,27 +219,22 @@ app.get("/play-recording/:call_control_id", async (req, res) => {
|
|
| 159 |
res.setHeader("Content-Type", "audio/mpeg");
|
| 160 |
res.setHeader("Content-Length", buffer.length);
|
| 161 |
res.send(buffer);
|
| 162 |
-
} catch (e) {
|
| 163 |
-
res.sendStatus(500);
|
| 164 |
-
}
|
| 165 |
});
|
| 166 |
|
|
|
|
| 167 |
app.post("/dial", async (req, res) => {
|
| 168 |
const { to } = req.body;
|
| 169 |
const voice = "Algieba";
|
| 170 |
log("DIAL", `Dialing ${to}...`);
|
| 171 |
|
| 172 |
try {
|
| 173 |
-
const response = await
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
from: TELNYX_FROM_NUMBER,
|
| 180 |
-
custom_headers: [{ name: "X-Voice-Selection", value: voice }]
|
| 181 |
-
}),
|
| 182 |
-
});
|
| 183 |
|
| 184 |
const data = await response.json();
|
| 185 |
if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
|
|
@@ -190,32 +245,26 @@ app.post("/dial", async (req, res) => {
|
|
| 190 |
target_number: to,
|
| 191 |
voice_used: voice,
|
| 192 |
status: "dialing",
|
| 193 |
-
|
| 194 |
-
is_playing: false, // Tracks if AI is currently making noise
|
| 195 |
recording_url: null,
|
| 196 |
raw_telnyx_url: null,
|
|
|
|
|
|
|
| 197 |
created_at: new Date().toISOString()
|
| 198 |
});
|
| 199 |
-
|
| 200 |
res.json({ ok: true });
|
| 201 |
-
} catch (err) {
|
| 202 |
-
res.status(500).json({ ok: false, error: err.message });
|
| 203 |
-
}
|
| 204 |
});
|
| 205 |
|
|
|
|
| 206 |
app.post("/call/hangup", async (req, res) => {
|
| 207 |
const { call_control_id } = req.body;
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
});
|
| 212 |
-
res.json({ ok: true });
|
| 213 |
-
} catch (err) {
|
| 214 |
-
res.status(500).json({ ok: false, error: err.message });
|
| 215 |
-
}
|
| 216 |
});
|
| 217 |
|
| 218 |
-
// βββ TELNYX WEBHOOK (
|
| 219 |
app.post("/webhook/call", async (req, res) => {
|
| 220 |
const type = req.body?.data?.event_type;
|
| 221 |
const payload = req.body?.data?.payload;
|
|
@@ -223,124 +272,267 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 223 |
|
| 224 |
if (!type) return res.sendStatus(200);
|
| 225 |
|
| 226 |
-
const
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
|
| 231 |
-
body: JSON.stringify(body)
|
| 232 |
-
});
|
| 233 |
-
if (!response.ok) log("TELNYX CMD ERROR", `[${action}] failed (${response.status})`);
|
| 234 |
-
return response;
|
| 235 |
-
} catch (e) {
|
| 236 |
-
log("TELNYX CMD NETWORK ERROR", e.message);
|
| 237 |
-
}
|
| 238 |
-
};
|
| 239 |
-
|
| 240 |
-
const updateLog = (statusUpdates) => {
|
| 241 |
-
const entry = callLogs.find(l => l.call_control_id === callId);
|
| 242 |
-
if (entry) Object.assign(entry, statusUpdates);
|
| 243 |
-
return entry;
|
| 244 |
-
};
|
| 245 |
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
setTimeout(async () => {
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
| 267 |
}
|
| 268 |
-
},
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
if (entry && entry.is_playing) {
|
| 284 |
-
log("BARGE-IN", "
|
| 285 |
-
|
| 286 |
-
await
|
| 287 |
}
|
| 288 |
-
return res.sendStatus(200); // Exit early, wait for final transcript
|
| 289 |
}
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
const voice = entry ? entry.voice_used : "Algieba";
|
| 296 |
-
log("TRANSCRIPT", `User said: "${transcript}"`);
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
try {
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
method: "POST",
|
| 302 |
-
headers: { "Content-Type": "application/json" },
|
| 303 |
-
body: JSON.stringify({ text: transcript, voice: voice, call_id: callId })
|
| 304 |
});
|
| 305 |
|
| 306 |
if (aiRes.ok) {
|
| 307 |
const aiData = await aiRes.json();
|
| 308 |
-
if (aiData.
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
|
|
|
|
|
|
| 314 |
});
|
|
|
|
|
|
|
| 315 |
}
|
| 316 |
}
|
| 317 |
} catch (e) { log("AI_SERVER_ERROR", e.message); }
|
| 318 |
}
|
| 319 |
}
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
case "call.hangup":
|
| 333 |
-
updateLog({ status: "completed", is_playing: false });
|
| 334 |
-
log("CALL ENDED", `Terminated.`);
|
| 335 |
-
break;
|
| 336 |
-
}
|
| 337 |
-
} catch (fatalError) {
|
| 338 |
-
log("WEBHOOK FATAL ERROR", fatalError.message);
|
| 339 |
}
|
|
|
|
|
|
|
| 340 |
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
});
|
| 343 |
|
| 344 |
server.listen(PORT, "0.0.0.0", () => {
|
| 345 |
-
log("STARTUP", `Telecom Server running on port ${PORT}`);
|
| 346 |
});
|
|
|
|
| 24 |
|
| 25 |
let callLogs = [];
|
| 26 |
|
| 27 |
+
// βββ UTILITY: GLOBAL TELNYX COMMAND DISPATCHER βββ
|
| 28 |
+
const sendTelnyxCmd = async (callId, action, body = {}) => {
|
| 29 |
+
try {
|
| 30 |
+
const response = await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/${action}`, {
|
| 31 |
+
method: "POST",
|
| 32 |
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
|
| 33 |
+
body: JSON.stringify(body)
|
| 34 |
+
});
|
| 35 |
+
if (!response.ok) log("TELNYX CMD ERROR", `[${action}] failed (${response.status}): ${await response.text()}`);
|
| 36 |
+
return response;
|
| 37 |
+
} catch (e) {
|
| 38 |
+
log("TELNYX CMD NETWORK ERROR", e.message);
|
| 39 |
+
}
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const updateLog = (callId, statusUpdates) => {
|
| 43 |
+
const entry = callLogs.find(l => l.call_control_id === callId);
|
| 44 |
+
if (entry) Object.assign(entry, 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++) {
|
| 51 |
+
let sign = (i & 0x80) ? -1 : 1;
|
| 52 |
+
let exponent = (~i >> 4) & 0x07;
|
| 53 |
+
let mantissa = ~i & 0x0f;
|
| 54 |
+
let sample = ((mantissa << 3) + 132) << exponent;
|
| 55 |
+
MU_LAW_TABLE[i] = sign * (sample - 132);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function decodeMuLaw(buffer) {
|
| 59 |
+
const pcm16 = new Int16Array(buffer.length);
|
| 60 |
+
for (let i = 0; i < buffer.length; i++) { pcm16[i] = MU_LAW_TABLE[buffer[i]]; }
|
| 61 |
+
return pcm16;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function calculateRMS(pcm16Array) {
|
| 65 |
+
let sum = 0;
|
| 66 |
+
for (let i = 0; i < pcm16Array.length; i++) sum += pcm16Array[i] * pcm16Array[i];
|
| 67 |
+
return Math.sqrt(sum / pcm16Array.length);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function pcm16ToWav(pcm16Array, sampleRate = 8000) {
|
| 71 |
+
const pcmBuffer = Buffer.from(pcm16Array.buffer);
|
| 72 |
+
const header = Buffer.alloc(44);
|
| 73 |
+
header.write("RIFF", 0);
|
| 74 |
+
header.writeUInt32LE(36 + pcmBuffer.length, 4);
|
| 75 |
+
header.write("WAVE", 8);
|
| 76 |
+
header.write("fmt ", 12);
|
| 77 |
+
header.writeUInt32LE(16, 16);
|
| 78 |
+
header.writeUInt16LE(1, 20); // PCM
|
| 79 |
+
header.writeUInt16LE(1, 22); // Mono
|
| 80 |
+
header.writeUInt32LE(sampleRate, 24);
|
| 81 |
+
header.writeUInt32LE(sampleRate * 2, 28); // ByteRate
|
| 82 |
+
header.writeUInt16LE(2, 32); // BlockAlign
|
| 83 |
+
header.writeUInt16LE(16, 34); // BitsPerSample
|
| 84 |
+
header.write("data", 36);
|
| 85 |
+
header.writeUInt32LE(pcmBuffer.length, 40);
|
| 86 |
+
return Buffer.concat([header, pcmBuffer]);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
// βββββββββββββββββββββββββββββββββββββββββββββ
|
| 90 |
// 1. EMBEDDED DASHBOARD
|
| 91 |
// βββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 96 |
<head>
|
| 97 |
<meta charset="UTF-8">
|
| 98 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 99 |
+
<title>Elite Telecom Control Panel</title>
|
| 100 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 101 |
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
|
| 102 |
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
|
|
|
|
| 158 |
<div className="bg-gray-800 rounded-lg overflow-hidden shadow-lg">
|
| 159 |
<table className="w-full text-left">
|
| 160 |
<thead className="bg-gray-900 text-gray-400 text-sm">
|
| 161 |
+
<tr><th className="p-4">Call ID</th><th className="p-4">Number</th><th className="p-4">Last Transcript</th><th className="p-4">Status</th><th className="p-4">Action</th><th className="p-4">Recording</th></tr>
|
| 162 |
</thead>
|
| 163 |
<tbody className="divide-y divide-gray-700">
|
| 164 |
{logs.map(log => (
|
| 165 |
<tr key={log.id}>
|
| 166 |
<td className="p-4 text-xs font-mono text-gray-500">{log.call_control_id.slice(0,8)}...</td>
|
| 167 |
<td className="p-4 font-mono">{log.target_number}</td>
|
| 168 |
+
<td className="p-4 text-xs text-gray-300 italic truncate max-w-[200px]" title={log.last_transcript}>
|
| 169 |
+
{log.last_transcript || "..."}
|
| 170 |
+
</td>
|
| 171 |
<td className="p-4">
|
| 172 |
<div className="flex flex-col gap-1">
|
| 173 |
<span className={\`inline-block w-fit px-2 py-1 rounded text-xs \${log.status === 'completed' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'}\`}>
|
|
|
|
| 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 |
+
) : <span className="text-gray-500 text-xs">Ended</span>}
|
|
|
|
|
|
|
| 185 |
</td>
|
| 186 |
<td className="p-4">
|
| 187 |
{log.recording_url ?
|
|
|
|
| 191 |
</td>
|
| 192 |
</tr>
|
| 193 |
))}
|
| 194 |
+
{logs.length === 0 && <tr><td colSpan="6" className="p-4 text-center text-gray-500">No calls registered yet</td></tr>}
|
| 195 |
</tbody>
|
| 196 |
</table>
|
| 197 |
</div>
|
|
|
|
| 205 |
`);
|
| 206 |
});
|
| 207 |
|
| 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);
|
|
|
|
| 215 |
try {
|
| 216 |
const s3Res = await fetch(logEntry.raw_telnyx_url);
|
| 217 |
if (!s3Res.ok) return res.sendStatus(s3Res.status);
|
|
|
|
| 219 |
res.setHeader("Content-Type", "audio/mpeg");
|
| 220 |
res.setHeader("Content-Length", buffer.length);
|
| 221 |
res.send(buffer);
|
| 222 |
+
} catch (e) { res.sendStatus(500); }
|
|
|
|
|
|
|
| 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 sendTelnyxCmd("", "v2/calls", {
|
| 233 |
+
connection_id: TELNYX_CONNECTION_ID,
|
| 234 |
+
to,
|
| 235 |
+
from: TELNYX_FROM_NUMBER,
|
| 236 |
+
custom_headers: [{ name: "X-Voice-Selection", value: voice }]
|
| 237 |
+
}, true);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
const data = await response.json();
|
| 240 |
if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
|
|
|
|
| 245 |
target_number: to,
|
| 246 |
voice_used: voice,
|
| 247 |
status: "dialing",
|
| 248 |
+
last_transcript: "",
|
|
|
|
| 249 |
recording_url: null,
|
| 250 |
raw_telnyx_url: null,
|
| 251 |
+
is_playing: false,
|
| 252 |
+
timers: {},
|
| 253 |
created_at: new Date().toISOString()
|
| 254 |
});
|
|
|
|
| 255 |
res.json({ ok: true });
|
| 256 |
+
} catch (err) { res.status(500).json({ ok: false, error: err.message }); }
|
|
|
|
|
|
|
| 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 |
+
await sendTelnyxCmd(call_control_id, "hangup");
|
| 264 |
+
res.json({ ok: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
|
|
|
| 272 |
|
| 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 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
+
switch (type) {
|
| 281 |
+
case "call.initiated":
|
| 282 |
+
log("INITIATED", `from=${payload?.from} to=${payload?.to}`);
|
| 283 |
+
if (payload?.direction === "incoming") {
|
| 284 |
+
log("INBOUND", "Answering inbound call...");
|
| 285 |
+
fetch(`${AI_SERVER_URL}/api/inbound-call`, {
|
| 286 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 287 |
+
body: JSON.stringify({ from: payload?.from, to: payload?.to, call_id: callId })
|
| 288 |
+
}).catch(() => {});
|
| 289 |
+
|
| 290 |
+
callLogs.push({
|
| 291 |
+
id: randomUUID(), call_control_id: callId, target_number: payload?.from,
|
| 292 |
+
voice_used: "Algieba", status: "in-progress", last_transcript: "", recording_url: null,
|
| 293 |
+
raw_telnyx_url: null, is_playing: false, timers: {}, created_at: new Date().toISOString()
|
| 294 |
+
});
|
| 295 |
+
await sendTelnyxCmd(callId, "answer");
|
| 296 |
+
}
|
| 297 |
+
break;
|
| 298 |
+
|
| 299 |
+
case "call.answered":
|
| 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 |
+
// Start WebSocket Media Stream to listen to the caller natively
|
| 306 |
+
const wsUrl = `wss://${req.headers.host}/audio-stream`;
|
| 307 |
+
await sendTelnyxCmd(callId, "stream_start", { stream_url: wsUrl, track: "inbound_track" });
|
| 308 |
+
|
| 309 |
+
if (entry) {
|
| 310 |
+
entry.timers.hangup = setTimeout(async () => {
|
| 311 |
+
if (entry.status === "completed") return;
|
| 312 |
+
log("HANGUP", `Enforcing 60-second limit.`);
|
| 313 |
+
await sendTelnyxCmd(callId, "hangup");
|
| 314 |
+
}, 60000);
|
| 315 |
+
}
|
| 316 |
+
break;
|
| 317 |
+
|
| 318 |
+
case "call.playback.ended":
|
| 319 |
+
case "call.playback.stopped":
|
| 320 |
+
updateLog(callId, { is_playing: false });
|
| 321 |
+
break;
|
| 322 |
+
|
| 323 |
+
case "call.recording.saved":
|
| 324 |
+
log("RECORDING URL", `\n\nβ¬οΈ DOWNLOAD RAW AUDIO HERE β¬οΈ\n${payload.recording_urls.mp3}\n`);
|
| 325 |
+
updateLog(callId, {
|
| 326 |
+
raw_telnyx_url: payload.recording_urls.mp3,
|
| 327 |
+
recording_url: `/play-recording/${callId}`
|
| 328 |
+
});
|
| 329 |
+
break;
|
| 330 |
+
|
| 331 |
+
case "call.hangup":
|
| 332 |
+
updateLog(callId, { status: "completed", is_playing: false });
|
| 333 |
+
if (entry && entry.timers) {
|
| 334 |
+
Object.values(entry.timers).forEach(clearTimeout);
|
| 335 |
+
entry.timers = {};
|
| 336 |
+
}
|
| 337 |
+
log("CALL ENDED", `Terminated.`);
|
| 338 |
+
break;
|
| 339 |
+
}
|
| 340 |
+
res.sendStatus(200);
|
| 341 |
+
});
|
| 342 |
|
| 343 |
+
// βββ WEBSOCKET (Zero-Dependency VAD & Turn Engine) βββ
|
| 344 |
+
wss.on("connection", (ws, req) => {
|
| 345 |
+
log("WS", `VAD WebSocket connected from ${req.socket.remoteAddress}`);
|
| 346 |
+
|
| 347 |
+
let pcmChunks = [];
|
| 348 |
+
let isSpeaking = false;
|
| 349 |
+
let silenceFrames = 0;
|
| 350 |
+
let callControlId = null;
|
| 351 |
+
let isProcessingTurn = false;
|
| 352 |
+
let hasGreeted = false;
|
| 353 |
+
|
| 354 |
+
const SILENCE_THRESHOLD_FRAMES = 25; // 25 frames * 20ms = 500ms of silence
|
| 355 |
+
const VOLUME_THRESHOLD = 400; // RMS Volume Threshold
|
| 356 |
+
|
| 357 |
+
ws.on("message", async (raw) => {
|
| 358 |
+
try {
|
| 359 |
+
const msg = JSON.parse(raw);
|
| 360 |
|
| 361 |
+
if (msg.event === "start") {
|
| 362 |
+
callControlId = msg.start?.call_control_id;
|
| 363 |
+
log("WS_STREAM", `Receiving audio for call ${callControlId}`);
|
| 364 |
+
|
| 365 |
+
// Smart Initial Greeting
|
| 366 |
setTimeout(async () => {
|
| 367 |
+
if (!hasGreeted && callControlId) {
|
| 368 |
+
const entry = callLogs.find(l => l.call_control_id === callControlId);
|
| 369 |
+
if (entry && entry.status === "in-progress") {
|
| 370 |
+
const voice = entry.voice_used || "Algieba";
|
| 371 |
+
log("SMART_GREETING", "No immediate human audio detected. Saying Hello.");
|
| 372 |
+
entry.is_playing = true;
|
| 373 |
+
hasGreeted = true;
|
| 374 |
+
await sendTelnyxCmd(callControlId, "playback_start", {
|
| 375 |
+
audio_url: `${AI_SERVER_URL}/play-audio?voice=${voice}&phrase=${encodeURIComponent("am I speaking with the business owner?")}`,
|
| 376 |
+
target_legs: "self"
|
| 377 |
+
});
|
| 378 |
+
}
|
| 379 |
}
|
| 380 |
+
}, 1500);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
if (msg.event === "media") {
|
| 384 |
+
const mulawBuffer = Buffer.from(msg.media.payload, "base64");
|
| 385 |
+
const pcm16 = decodeMuLaw(mulawBuffer);
|
| 386 |
+
const rms = calculateRMS(pcm16);
|
| 387 |
+
|
| 388 |
+
// ββ 1. NOISE DETECTED (Human is speaking) ββ
|
| 389 |
+
if (rms > VOLUME_THRESHOLD) {
|
| 390 |
+
hasGreeted = true; // They spoke, disable smart greeting timer
|
| 391 |
+
silenceFrames = 0;
|
| 392 |
+
pcmChunks.push(pcm16);
|
| 393 |
+
|
| 394 |
+
if (!isSpeaking) {
|
| 395 |
+
isSpeaking = true;
|
| 396 |
+
isProcessingTurn = false;
|
| 397 |
+
log("VAD", "Human started speaking.");
|
| 398 |
+
|
| 399 |
+
// ββ BARGE IN ββ
|
| 400 |
+
const entry = callLogs.find(l => l.call_control_id === callControlId);
|
| 401 |
if (entry && entry.is_playing) {
|
| 402 |
+
log("BARGE-IN", "Interrupting AI playback.");
|
| 403 |
+
entry.is_playing = false;
|
| 404 |
+
await sendTelnyxCmd(callControlId, "playback_stop");
|
| 405 |
}
|
|
|
|
| 406 |
}
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// ββ 2. SILENCE DETECTED ββ
|
| 410 |
+
else if (isSpeaking) {
|
| 411 |
+
pcmChunks.push(pcm16);
|
| 412 |
+
silenceFrames++;
|
| 413 |
+
|
| 414 |
+
// ββ 3. SOFT TURN TRIGGER (500ms silence limit reached) ββ
|
| 415 |
+
if (silenceFrames > SILENCE_THRESHOLD_FRAMES && !isProcessingTurn) {
|
| 416 |
+
log("VAD", "500ms silence detected. Turn ended.");
|
| 417 |
+
isSpeaking = false;
|
| 418 |
+
isProcessingTurn = true;
|
| 419 |
+
silenceFrames = 0;
|
| 420 |
+
|
| 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 |
+
// If the noise burst was less than ~200ms total, ignore it (mic bump/cough)
|
| 432 |
+
if (totalLength < 8000 * 0.2) {
|
| 433 |
+
isProcessingTurn = false;
|
| 434 |
+
return;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
const wavBuffer = pcm16ToWav(finalPcm, 8000);
|
| 438 |
+
|
| 439 |
+
// ββ IMMEDIATE BACKCHANNEL HACK (0ms perceived latency) ββ
|
| 440 |
+
const fillers = ["Right.", "Got it.", "Okay."];
|
| 441 |
+
const filler = fillers[Math.floor(Math.random() * fillers.length)];
|
| 442 |
+
|
| 443 |
+
if (entry) entry.is_playing = true;
|
| 444 |
+
sendTelnyxCmd(callControlId, "playback_start", {
|
| 445 |
+
audio_url: `${AI_SERVER_URL}/play-audio?voice=${voice}&phrase=${encodeURIComponent(filler)}`,
|
| 446 |
+
target_legs: "self"
|
| 447 |
+
}).catch(()=>{});
|
| 448 |
+
|
| 449 |
+
// ββ BACKGROUND AI PROCESSING ββ
|
| 450 |
try {
|
| 451 |
+
const aiRes = await fetch(`${AI_SERVER_URL}/api/process-audio-turn?voice=${voice}&call_id=${callControlId}`, {
|
| 452 |
+
method: "POST", headers: { "Content-Type": "audio/wav" }, body: wavBuffer
|
|
|
|
|
|
|
|
|
|
| 453 |
});
|
| 454 |
|
| 455 |
if (aiRes.ok) {
|
| 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 reply: ${aiData.raw_text}`);
|
| 462 |
+
if (entry) entry.is_playing = true;
|
| 463 |
+
await sendTelnyxCmd(callControlId, "playback_start", {
|
| 464 |
+
audio_url: aiData.speak_url, target_legs: "self"
|
| 465 |
});
|
| 466 |
+
} else {
|
| 467 |
+
log("TURN_DROPPED", "User resumed speaking. Dropping response.");
|
| 468 |
}
|
| 469 |
}
|
| 470 |
} catch (e) { log("AI_SERVER_ERROR", e.message); }
|
| 471 |
}
|
| 472 |
}
|
| 473 |
+
}
|
| 474 |
+
} catch (err) { log("WS PARSE ERROR", err.message); }
|
| 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}`);
|
| 534 |
});
|
| 535 |
|
| 536 |
server.listen(PORT, "0.0.0.0", () => {
|
| 537 |
+
log("STARTUP", `Elite Telecom Server running on port ${PORT}`);
|
| 538 |
});
|