Update app.js
Browse files
app.js
CHANGED
|
@@ -1,263 +1,301 @@
|
|
| 1 |
-
import express from "express";
|
|
|
|
| 2 |
import { WebSocketServer } from "ws";
|
| 3 |
|
| 4 |
-
const app = express();
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
process.env.
|
| 12 |
-
|
| 13 |
-
const
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
const
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
const
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
pendingCalls.set(call_control_id, action);
|
| 48 |
-
|
| 49 |
-
if (action === "reject") { await sendTelnyxCmd(call_control_id, "hangup");
|
| 50 |
-
pendingCalls.delete(call_control_id); } else { await
|
| 51 |
-
sendTelnyxCmd(call_control_id, "answer"); } res.json({ ok: true }); });
|
| 52 |
-
|
| 53 |
-
app.post("/api/hangup", async (req, res) => { await
|
| 54 |
-
sendTelnyxCmd(req.body.call_control_id, "hangup"); res.json({ ok: true }); });
|
| 55 |
-
|
| 56 |
-
app.post("/api/send-sms", async (req, res) => { log("SMS", Attempting outbound
|
| 57 |
-
SMS to ${req.body.to}...); try { const sendRequest = async (fromNumber) => {
|
| 58 |
-
const payload = { from: fromNumber, to: req.body.to, text: req.body.text }; if
|
| 59 |
-
(TELNYX_MESSAGING_PROFILE_ID) payload.messaging_profile_id =
|
| 60 |
-
TELNYX_MESSAGING_PROFILE_ID;
|
| 61 |
-
|
| 62 |
-
return await fetch("https://api.telnyx.com/v2/messages", {
|
| 63 |
-
method: "POST",
|
| 64 |
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
|
| 65 |
-
body: JSON.stringify(payload)
|
| 66 |
-
});
|
| 67 |
-
};
|
| 68 |
-
|
| 69 |
-
// 1. Try sending with the Alpha Sender (ColorizeWeb) first
|
| 70 |
-
let response = await sendRequest(TELNYX_ALPHA_SENDER);
|
| 71 |
-
let data = await response.json();
|
| 72 |
-
|
| 73 |
-
// 2. If rejected due to strict Alpha Sender rules (40305), fallback to the real US phone number instantly
|
| 74 |
-
if (!response.ok && data.errors && data.errors.some(e => e.code === '40305' || e.code === '40012')) {
|
| 75 |
-
log("SMS WARNING", `Alpha Sender rejected. Falling back to standard US number ${TELNYX_FROM_NUMBER}...`);
|
| 76 |
-
response = await sendRequest(TELNYX_FROM_NUMBER);
|
| 77 |
-
data = await response.json();
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
if (!response.ok) {
|
| 81 |
-
log("SMS ERROR", JSON.stringify(data));
|
| 82 |
-
} else {
|
| 83 |
-
log("SMS SUCCESS", `Message accepted by Telnyx for delivery to ${req.body.to}`);
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
res.json(data);
|
| 87 |
-
|
| 88 |
-
} catch(e) { log("SMS NETWORK ERROR", e.message); res.status(500).json({ error:
|
| 89 |
-
e.message }); } });
|
| 90 |
-
|
| 91 |
-
app.post("/dial", async (req, res) => { const { to, lead_id, action = "manual" }
|
| 92 |
-
= req.body; log("DIAL", Outbound to ${to} (Mode: ${action})); try { const
|
| 93 |
-
response = await fetch("https://api.telnyx.com/v2/calls", { method: "POST",
|
| 94 |
-
headers: { "Content-Type": "application/json", Authorization: Bearer
|
| 95 |
-
${TELNYX_API_KEY} }, body: JSON.stringify({ connection_id: TELNYX_CONNECTION_ID,
|
| 96 |
-
to, from: TELNYX_FROM_NUMBER }) }); const data = await response.json(); if
|
| 97 |
-
(!response.ok) return res.status(400).json({ ok: false, error:
|
| 98 |
-
data?.errors?.[0]?.detail });
|
| 99 |
-
|
| 100 |
-
const ccid = data.data.call_control_id;
|
| 101 |
-
pendingCalls.set(ccid, action);
|
| 102 |
-
if (lead_id) activeLeadIds.set(ccid, lead_id);
|
| 103 |
-
|
| 104 |
-
await fetch(`${AI_SERVER_URL}/api/log-outbound`, {
|
| 105 |
-
method: "POST", headers: { "Content-Type": "application/json" },
|
| 106 |
-
body: JSON.stringify({ call_control_id: ccid, action, lead_id })
|
| 107 |
-
}).catch(() => {});
|
| 108 |
-
|
| 109 |
-
res.json({ ok: true, call_control_id: ccid });
|
| 110 |
-
|
| 111 |
-
} catch (err) { res.status(500).json({ ok: false, error: err.message }); } });
|
| 112 |
-
|
| 113 |
-
app.post("/webhook/call", async (req, res) => { res.sendStatus(200); const type
|
| 114 |
-
= req.body?.data?.event_type; const payload = req.body?.data?.payload; const
|
| 115 |
-
callId = payload?.call_control_id; if (!type) return;
|
| 116 |
-
|
| 117 |
-
switch (type) { case "call.initiated": if (payload?.direction === "incoming") {
|
| 118 |
-
const fromNumber = payload.from; const now = Date.now();
|
| 119 |
-
|
| 120 |
-
if (incomingCallDebounce.has(fromNumber)) {
|
| 121 |
-
if (now - incomingCallDebounce.get(fromNumber) < 10000) {
|
| 122 |
-
log("INCOMING DEBOUNCED", `Ignoring duplicate payload from ${fromNumber}`);
|
| 123 |
-
return;
|
| 124 |
-
}
|
| 125 |
-
}
|
| 126 |
-
incomingCallDebounce.set(fromNumber, now);
|
| 127 |
-
|
| 128 |
-
log("INCOMING", `From: ${fromNumber}`);
|
| 129 |
-
pendingCalls.set(callId, "waiting");
|
| 130 |
-
|
| 131 |
-
await fetch(`${AI_SERVER_URL}/api/incoming-call`, {
|
| 132 |
-
method: "POST", headers: { "Content-Type": "application/json" },
|
| 133 |
-
body: JSON.stringify({ call_control_id: callId, from: fromNumber })
|
| 134 |
-
}).catch(()=>{});
|
| 135 |
-
}
|
| 136 |
-
break;
|
| 137 |
-
|
| 138 |
-
case "call.answered":
|
| 139 |
-
if (payload?.client_state) {
|
| 140 |
-
const legACallId = Buffer.from(payload.client_state, "base64").toString("ascii");
|
| 141 |
-
log("BRIDGING", `Bridging Leg A (${legACallId}) ↔ Leg B (${callId})`);
|
| 142 |
-
await sendTelnyxCmd(callId, "bridge", { call_control_id: legACallId });
|
| 143 |
-
break;
|
| 144 |
-
}
|
| 145 |
-
|
| 146 |
-
const actionType = pendingCalls.get(callId) || "ai";
|
| 147 |
-
|
| 148 |
-
await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
|
| 149 |
-
|
| 150 |
-
/* if (actionType === "manual") {
|
| 151 |
-
log("BRIDGING", `Dialing SIP for manual takeover...`);
|
| 152 |
-
await sendTelnyxCmd(null, "dial", {
|
| 153 |
-
to: `sip:${SIP_USERNAME}@sip.telnyx.com`, from: TELNYX_FROM_NUMBER,
|
| 154 |
-
connection_id: TELNYX_CONNECTION_ID, client_state: Buffer.from(callId).toString("base64")
|
| 155 |
-
});
|
| 156 |
-
} */
|
| 157 |
-
if (actionType === "manual") {
|
| 158 |
-
|
| 159 |
-
log("BRIDGING", Dialing Agent Cell for manual takeover...); // Hardcode your
|
| 160 |
-
actual personal cell phone number here for the launch const MY_CELL_NUMBER =
|
| 161 |
-
"+2347066923490"; // <--- PUT YOUR REAL PHONE NUMBER HERE
|
| 162 |
-
|
| 163 |
-
await sendTelnyxCmd(null, "dial", { to: MY_CELL_NUMBER, from:
|
| 164 |
-
TELNYX_FROM_NUMBER, connection_id: TELNYX_CONNECTION_ID, client_state:
|
| 165 |
-
Buffer.from(callId).toString("base64") }); } else { log("AI CONNECTING",
|
| 166 |
-
Starting stream for ${callId}); await sendTelnyxCmd(callId, "playback_start", {
|
| 167 |
-
audio_url: http://${req.headers.host}/ambience.mp3, target_legs: "caller", loop:
|
| 168 |
-
"infinity" }); await sendTelnyxCmd(callId, "streaming_start", { stream_url:
|
| 169 |
-
wss://${req.headers.host}/audio-stream, stream_track: "inbound_track" }); await
|
| 170 |
-
sendTelnyxCmd(callId, "transcription_start", { transcription_engine: "Telnyx",
|
| 171 |
-
transcription_tracks: "inbound" }); setTimeout(async () => { log("HANGUP",
|
| 172 |
-
Enforcing 90s limit on ${callId}); await sendTelnyxCmd(callId, "hangup");
|
| 173 |
-
}, 90000); } break;
|
| 174 |
-
|
| 175 |
-
case "call.transcription":
|
| 176 |
-
if (!payload?.transcription_data?.is_final) break;
|
| 177 |
try {
|
| 178 |
-
const
|
| 179 |
-
|
| 180 |
-
|
|
|
|
| 181 |
});
|
| 182 |
-
if (
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
console.log([RECORDING ERROR] AI Server returned status: ${r.status} - ${await
|
| 198 |
-
r.text()});
|
| 199 |
-
|
| 200 |
-
console.log(Recording URL: ${payload.recording_urls.mp3}); }) .catch((err) => {
|
| 201 |
-
console.log([RECORDING NET ERROR] Failed to hit AI Server:, err.message); });
|
| 202 |
-
|
| 203 |
}
|
| 204 |
-
|
|
|
|
| 205 |
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
method: "POST", headers: { "Content-Type": "application/json" },
|
| 211 |
-
body: JSON.stringify({ call_control_id: callId })
|
| 212 |
-
}).catch(() => {});
|
| 213 |
-
break;
|
| 214 |
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
true; const greetingPhrase = "Hi, this is Nathan."; await
|
| 225 |
-
sendTelnyxCmd(callControlId, "playback_start", { audio_url:
|
| 226 |
-
${AI_SERVER_URL}/play-audio?voice=${ACTIVE_VOICE}&phrase=${encodeURIComponent(greetingPhrase)},
|
| 227 |
-
target_legs: "self" }); fetch(${AI_SERVER_URL}/api/register-greeting, { method:
|
| 228 |
-
"POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({
|
| 229 |
-
call_id: callControlId, phrase: greetingPhrase, lead_id:
|
| 230 |
-
activeLeadIds.get(callControlId) || null }) }).catch(() => {}); }, 1500); }
|
| 231 |
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
if (
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
}
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
}
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
}
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
}
|
| 258 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
-
server.listen(PORT, "0.0.0.0", () => {
|
| 263 |
-
running on port ${PORT});
|
|
|
|
|
|
| 1 |
+
import express from "express";
|
| 2 |
+
import { createServer } from "http";
|
| 3 |
import { WebSocketServer } from "ws";
|
| 4 |
|
| 5 |
+
const app = express();
|
| 6 |
+
const server = createServer(app);
|
| 7 |
+
const wss = new WebSocketServer({ server, path: "/audio-stream" });
|
| 8 |
+
|
| 9 |
+
app.use(express.static("public", { index: false }));
|
| 10 |
+
app.use(express.json());
|
| 11 |
+
|
| 12 |
+
const PORT = process.env.PORT || 7860;
|
| 13 |
+
const TELNYX_API_KEY = process.env.TELNYX_API_KEY;
|
| 14 |
+
const TELNYX_CONNECTION_ID = process.env.TELNYX_CONNECTION_ID;
|
| 15 |
+
const TELNYX_FROM_NUMBER = process.env.TELNYX_FROM_NUMBER;
|
| 16 |
+
const AI_SERVER_URL = process.env.AI_SERVER_URL;
|
| 17 |
+
const TELNYX_ALPHA_SENDER = process.env.TELNYX_ALPHA_SENDER || TELNYX_FROM_NUMBER;
|
| 18 |
+
const TELNYX_MESSAGING_PROFILE_ID = process.env.TELNYX_MESSAGING_PROFILE_ID;
|
| 19 |
+
|
| 20 |
+
const ACTIVE_VOICE = "Enceladus_3";
|
| 21 |
+
const log = (tag, ...args) => console.log(`[${new Date().toISOString()}] [${tag}]`, ...args);
|
| 22 |
+
|
| 23 |
+
let pendingCalls = new Map();
|
| 24 |
+
let activeLeadIds = new Map();
|
| 25 |
+
const incomingCallDebounce = new Map();
|
| 26 |
+
|
| 27 |
+
// ── MU-LAW VAD ──
|
| 28 |
+
const MU_LAW_TABLE = new Int16Array(256);
|
| 29 |
+
for (let i = 0; i < 256; i++) {
|
| 30 |
+
const sign = (i & 0x80) ? -1 : 1;
|
| 31 |
+
const exponent = (~i >> 4) & 0x07;
|
| 32 |
+
const mantissa = ~i & 0x0f;
|
| 33 |
+
const sample = ((mantissa << 3) + 132) << exponent;
|
| 34 |
+
MU_LAW_TABLE[i] = sign * (sample - 132);
|
| 35 |
+
}
|
| 36 |
+
function decodeMuLaw(buffer) {
|
| 37 |
+
const out = new Int16Array(buffer.length);
|
| 38 |
+
for (let i = 0; i < buffer.length; i++) out[i] = MU_LAW_TABLE[buffer[i]];
|
| 39 |
+
return out;
|
| 40 |
+
}
|
| 41 |
+
function calculateRMS(pcm) {
|
| 42 |
+
let sum = 0;
|
| 43 |
+
for (let i = 0; i < pcm.length; i++) sum += pcm[i] * pcm[i];
|
| 44 |
+
return Math.sqrt(sum / pcm.length);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const sendTelnyxCmd = async (callId, action, body = {}) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
try {
|
| 49 |
+
const url = callId ? `https://api.telnyx.com/v2/calls/${callId}/actions/${action}` : `https://api.telnyx.com/v2/calls`;
|
| 50 |
+
const response = await fetch(url, {
|
| 51 |
+
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
|
| 52 |
+
body: JSON.stringify(body)
|
| 53 |
});
|
| 54 |
+
if (!response.ok) log("TELNYX CMD ERROR", `[${action}] ${await response.text()}`);
|
| 55 |
+
return response;
|
| 56 |
+
} catch (e) { log("TELNYX CMD NETWORK ERROR", e.message); }
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
app.post("/api/call-action", async (req, res) => {
|
| 60 |
+
const { call_control_id, action, lead_id } = req.body;
|
| 61 |
+
if (!pendingCalls.has(call_control_id)) return res.status(404).json({ error: "Call not pending" });
|
| 62 |
|
| 63 |
+
if (lead_id) activeLeadIds.set(call_control_id, lead_id);
|
| 64 |
+
pendingCalls.set(call_control_id, action);
|
| 65 |
+
|
| 66 |
+
if (action === "reject") {
|
| 67 |
+
await sendTelnyxCmd(call_control_id, "hangup");
|
| 68 |
+
pendingCalls.delete(call_control_id);
|
| 69 |
+
} else {
|
| 70 |
+
await sendTelnyxCmd(call_control_id, "answer");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
| 72 |
+
res.json({ ok: true });
|
| 73 |
+
});
|
| 74 |
|
| 75 |
+
app.post("/api/hangup", async (req, res) => {
|
| 76 |
+
await sendTelnyxCmd(req.body.call_control_id, "hangup");
|
| 77 |
+
res.json({ ok: true });
|
| 78 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
+
app.post("/api/send-sms", async (req, res) => {
|
| 81 |
+
log("SMS", `Attempting outbound SMS to ${req.body.to}...`);
|
| 82 |
+
try {
|
| 83 |
+
const sendRequest = async (fromNumber) => {
|
| 84 |
+
const payload = { from: fromNumber, to: req.body.to, text: req.body.text };
|
| 85 |
+
if (TELNYX_MESSAGING_PROFILE_ID) payload.messaging_profile_id = TELNYX_MESSAGING_PROFILE_ID;
|
| 86 |
+
|
| 87 |
+
return await fetch("https://api.telnyx.com/v2/messages", {
|
| 88 |
+
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
|
| 89 |
+
body: JSON.stringify(payload)
|
| 90 |
+
});
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
let response = await sendRequest(TELNYX_ALPHA_SENDER);
|
| 94 |
+
let data = await response.json();
|
| 95 |
+
|
| 96 |
+
if (!response.ok && data.errors && data.errors.some(e => e.code === '40305' || e.code === '40012')) {
|
| 97 |
+
log("SMS WARNING", `Alpha Sender rejected. Falling back to standard US number ${TELNYX_FROM_NUMBER}...`);
|
| 98 |
+
response = await sendRequest(TELNYX_FROM_NUMBER);
|
| 99 |
+
data = await response.json();
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
if (!response.ok) log("SMS ERROR", JSON.stringify(data));
|
| 103 |
+
else log("SMS SUCCESS", `Message accepted by Telnyx for delivery to ${req.body.to}`);
|
| 104 |
+
|
| 105 |
+
res.json(data);
|
| 106 |
+
} catch(e) {
|
| 107 |
+
log("SMS NETWORK ERROR", e.message);
|
| 108 |
+
res.status(500).json({ error: e.message });
|
| 109 |
+
}
|
| 110 |
+
});
|
| 111 |
|
| 112 |
+
app.post("/dial", async (req, res) => {
|
| 113 |
+
const { to, lead_id, action = "manual" } = req.body;
|
| 114 |
+
log("DIAL", `Outbound to ${to} (Mode: ${action})`);
|
| 115 |
+
try {
|
| 116 |
+
const response = await fetch("https://api.telnyx.com/v2/calls", {
|
| 117 |
+
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
|
| 118 |
+
body: JSON.stringify({ connection_id: TELNYX_CONNECTION_ID, to, from: TELNYX_FROM_NUMBER })
|
| 119 |
+
});
|
| 120 |
+
const data = await response.json();
|
| 121 |
+
if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
|
| 122 |
|
| 123 |
+
const ccid = data.data.call_control_id;
|
| 124 |
+
pendingCalls.set(ccid, action);
|
| 125 |
+
if (lead_id) activeLeadIds.set(ccid, lead_id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
+
await fetch(`${AI_SERVER_URL}/api/log-outbound`, {
|
| 128 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 129 |
+
body: JSON.stringify({ call_control_id: ccid, action, lead_id })
|
| 130 |
+
}).catch(() => {});
|
| 131 |
+
|
| 132 |
+
res.json({ ok: true, call_control_id: ccid });
|
| 133 |
+
} catch (err) { res.status(500).json({ ok: false, error: err.message }); }
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
app.post("/webhook/call", async (req, res) => {
|
| 137 |
+
res.sendStatus(200);
|
| 138 |
+
const type = req.body?.data?.event_type;
|
| 139 |
+
const payload = req.body?.data?.payload;
|
| 140 |
+
const callId = payload?.call_control_id;
|
| 141 |
+
if (!type) return;
|
| 142 |
+
|
| 143 |
+
switch (type) {
|
| 144 |
+
case "call.initiated":
|
| 145 |
+
if (payload?.direction === "incoming") {
|
| 146 |
+
const fromNumber = payload.from;
|
| 147 |
+
const now = Date.now();
|
| 148 |
+
|
| 149 |
+
if (incomingCallDebounce.has(fromNumber)) {
|
| 150 |
+
if (now - incomingCallDebounce.get(fromNumber) < 10000) {
|
| 151 |
+
log("INCOMING DEBOUNCED", `Ignoring duplicate payload from ${fromNumber}`);
|
| 152 |
+
return;
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
incomingCallDebounce.set(fromNumber, now);
|
| 156 |
+
|
| 157 |
+
log("INCOMING", `From: ${fromNumber}`);
|
| 158 |
+
pendingCalls.set(callId, "waiting");
|
| 159 |
+
|
| 160 |
+
await fetch(`${AI_SERVER_URL}/api/incoming-call`, {
|
| 161 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 162 |
+
body: JSON.stringify({ call_control_id: callId, from: fromNumber })
|
| 163 |
+
}).catch(()=>{});
|
| 164 |
+
}
|
| 165 |
+
break;
|
| 166 |
+
|
| 167 |
+
case "call.answered":
|
| 168 |
+
if (payload?.client_state) {
|
| 169 |
+
const legACallId = Buffer.from(payload.client_state, "base64").toString("ascii");
|
| 170 |
+
log("BRIDGING", `Agent picked up! Stopping ringback and Bridging Leg A (${legACallId}) ↔ Leg B (${callId})`);
|
| 171 |
+
|
| 172 |
+
// Stop the ringing sound for the customer
|
| 173 |
+
await sendTelnyxCmd(legACallId, "playback_stop");
|
| 174 |
+
|
| 175 |
+
// Bridge them
|
| 176 |
+
await sendTelnyxCmd(callId, "bridge", { call_control_id: legACallId });
|
| 177 |
+
break;
|
| 178 |
+
}
|
| 179 |
|
| 180 |
+
const actionType = pendingCalls.get(callId) || "ai";
|
| 181 |
+
|
| 182 |
+
await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
|
| 183 |
+
|
| 184 |
+
if (actionType === "manual") {
|
| 185 |
+
log("BRIDGING", `Dialing Agent Cell (+2348069234734) for manual takeover...`);
|
| 186 |
+
|
| 187 |
+
// 1. Play ringing to the customer
|
| 188 |
+
await sendTelnyxCmd(callId, "playback_start", {
|
| 189 |
+
audio_url: "https://actions.telnyx.com/sample-assets/ringback.wav",
|
| 190 |
+
target_legs: "self"
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
// 2. Dial your cell phone
|
| 194 |
+
const MY_CELL_NUMBER = "+2347066923490";
|
| 195 |
+
await sendTelnyxCmd(null, "dial", {
|
| 196 |
+
to: MY_CELL_NUMBER,
|
| 197 |
+
from: TELNYX_FROM_NUMBER,
|
| 198 |
+
connection_id: TELNYX_CONNECTION_ID,
|
| 199 |
+
client_state: Buffer.from(callId).toString("base64")
|
| 200 |
+
});
|
| 201 |
+
} else {
|
| 202 |
+
log("AI CONNECTING", `Starting stream for ${callId}`);
|
| 203 |
+
await sendTelnyxCmd(callId, "playback_start", { audio_url: `http://${req.headers.host}/ambience.mp3`, target_legs: "caller", loop: "infinity" });
|
| 204 |
+
await sendTelnyxCmd(callId, "streaming_start", { stream_url: `wss://${req.headers.host}/audio-stream`, stream_track: "inbound_track" });
|
| 205 |
+
await sendTelnyxCmd(callId, "transcription_start", { transcription_engine: "Telnyx", transcription_tracks: "inbound" });
|
| 206 |
+
setTimeout(async () => { log("HANGUP", `Enforcing 90s limit on ${callId}`); await sendTelnyxCmd(callId, "hangup"); }, 90000);
|
| 207 |
}
|
| 208 |
+
break;
|
| 209 |
+
|
| 210 |
+
case "call.transcription":
|
| 211 |
+
if (!payload?.transcription_data?.is_final) break;
|
| 212 |
+
try {
|
| 213 |
+
const aiRes = await fetch(`${AI_SERVER_URL}/api/process-text-turn`, {
|
| 214 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 215 |
+
body: JSON.stringify({ text: payload.transcription_data.transcript.trim(), call_id: callId, lead_id: activeLeadIds.get(callId) || null })
|
| 216 |
+
});
|
| 217 |
+
if (aiRes.ok) {
|
| 218 |
+
const aiData = await aiRes.json();
|
| 219 |
+
if (aiData.speak_url) await sendTelnyxCmd(callId, "playback_start", { audio_url: aiData.speak_url, target_legs: "self" });
|
| 220 |
}
|
| 221 |
+
} catch (e) { log("AI_SERVER_ERROR", e.message); }
|
| 222 |
+
break;
|
| 223 |
+
|
| 224 |
+
case "call.recording.saved":
|
| 225 |
+
if (payload.recording_urls?.mp3) {
|
| 226 |
+
await fetch(`${AI_SERVER_URL}/api/save-recording`, {
|
| 227 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 228 |
+
body: JSON.stringify({ call_control_id: callId, recording_url: payload.recording_urls.mp3 })
|
| 229 |
+
})
|
| 230 |
+
.then(async (r) => { if (!r.ok) log("RECORDING ERROR", `AI Server status: ${r.status}`); })
|
| 231 |
+
.catch((err) => log("RECORDING NET ERROR", err.message));
|
| 232 |
}
|
| 233 |
+
break;
|
| 234 |
+
|
| 235 |
+
case "call.hangup":
|
| 236 |
+
log("HANGUP", `Call ended. Reason: ${payload.hangup_cause}`);
|
| 237 |
+
pendingCalls.delete(callId);
|
| 238 |
+
activeLeadIds.delete(callId);
|
| 239 |
+
await fetch(`${AI_SERVER_URL}/api/call-ended`, {
|
| 240 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 241 |
+
body: JSON.stringify({ call_control_id: callId })
|
| 242 |
+
}).catch(() => {});
|
| 243 |
+
break;
|
| 244 |
}
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
wss.on("connection", (ws) => {
|
| 248 |
+
let isSpeaking = false;
|
| 249 |
+
let silenceFrames = 0;
|
| 250 |
+
let callControlId = null;
|
| 251 |
+
let hasPlayedFiller = false;
|
| 252 |
+
let hasGreeted = false;
|
| 253 |
+
let totalNoiseFrames = 0;
|
| 254 |
+
|
| 255 |
+
ws.on("message", async (raw) => {
|
| 256 |
+
try {
|
| 257 |
+
const msg = JSON.parse(raw);
|
| 258 |
+
if (msg.event === "start") {
|
| 259 |
+
callControlId = msg.start?.call_control_id;
|
| 260 |
+
setTimeout(async () => {
|
| 261 |
+
if (hasGreeted || !callControlId) return;
|
| 262 |
+
hasGreeted = true;
|
| 263 |
+
const greetingPhrase = "Hi, this is Nathan.";
|
| 264 |
+
await sendTelnyxCmd(callControlId, "playback_start", { audio_url: `${AI_SERVER_URL}/play-audio?voice=${ACTIVE_VOICE}&phrase=${encodeURIComponent(greetingPhrase)}`, target_legs: "self" });
|
| 265 |
+
fetch(`${AI_SERVER_URL}/api/register-greeting`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ call_id: callControlId, phrase: greetingPhrase, lead_id: activeLeadIds.get(callControlId) || null }) }).catch(() => {});
|
| 266 |
+
}, 1500);
|
| 267 |
+
}
|
| 268 |
|
| 269 |
+
if (msg.event === "media") {
|
| 270 |
+
const pcm16 = decodeMuLaw(Buffer.from(msg.media.payload, "base64"));
|
| 271 |
+
const rms = calculateRMS(pcm16);
|
| 272 |
+
|
| 273 |
+
if (rms > 500) {
|
| 274 |
+
hasGreeted = true;
|
| 275 |
+
silenceFrames = 0;
|
| 276 |
+
totalNoiseFrames++;
|
| 277 |
+
if (!isSpeaking) {
|
| 278 |
+
isSpeaking = true;
|
| 279 |
+
hasPlayedFiller = false;
|
| 280 |
+
await sendTelnyxCmd(callControlId, "playback_stop");
|
| 281 |
+
}
|
| 282 |
+
} else if (isSpeaking) {
|
| 283 |
+
silenceFrames++;
|
| 284 |
+
if (silenceFrames > 35 && !hasPlayedFiller) {
|
| 285 |
+
hasPlayedFiller = true;
|
| 286 |
+
isSpeaking = false;
|
| 287 |
+
if (totalNoiseFrames > 40) {
|
| 288 |
+
const fillers = ["Right.", "Got it.", "Okay.", "Yeah.", "Ah, okay."];
|
| 289 |
+
sendTelnyxCmd(callControlId, "playback_start", { audio_url: `${AI_SERVER_URL}/play-audio?voice=${ACTIVE_VOICE}&phrase=${encodeURIComponent(fillers[Math.floor(Math.random() * fillers.length)])}`, target_legs: "self" }).catch(() => {});
|
| 290 |
+
}
|
| 291 |
+
totalNoiseFrames = 0;
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
} catch {}
|
| 296 |
+
});
|
| 297 |
+
});
|
| 298 |
|
| 299 |
+
server.listen(PORT, "0.0.0.0", () => {
|
| 300 |
+
log("STARTUP", `Telecom routing server running on port ${PORT}`);
|
| 301 |
+
});
|