Jjj / app.js
Pepguy's picture
Update app.js
4ec8446 verified
Raw
History Blame Contribute Delete
13.4 kB
import express from "express";
import { createServer } from "http";
import { WebSocketServer } from "ws";
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server, path: "/audio-stream" });
app.use(express.static("public", { index: false }));
app.use(express.json());
const PORT = process.env.PORT || 7860;
const TELNYX_API_KEY = process.env.TELNYX_API_KEY;
const TELNYX_CONNECTION_ID = process.env.TELNYX_CONNECTION_ID;
const TELNYX_FROM_NUMBER = process.env.TELNYX_FROM_NUMBER;
const AI_SERVER_URL = process.env.AI_SERVER_URL;
const TELNYX_ALPHA_SENDER = process.env.TELNYX_ALPHA_SENDER || TELNYX_FROM_NUMBER;
const TELNYX_MESSAGING_PROFILE_ID = process.env.TELNYX_MESSAGING_PROFILE_ID;
const ACTIVE_VOICE = "Enceladus_3";
const log = (tag, ...args) => console.log(`[${new Date().toISOString()}] [${tag}]`, ...args);
let pendingCalls = new Map();
let activeLeadIds = new Map();
const incomingCallDebounce = new Map();
const MY_CELL_NUMBER = "+2347066923490"; // <-- Your agent phone number
// ── MU-LAW VAD ──
const MU_LAW_TABLE = new Int16Array(256);
for (let i = 0; i < 256; i++) {
const sign = (i & 0x80) ? -1 : 1;
const exponent = (~i >> 4) & 0x07;
const mantissa = ~i & 0x0f;
const sample = ((mantissa << 3) + 132) << exponent;
MU_LAW_TABLE[i] = sign * (sample - 132);
}
function decodeMuLaw(buffer) {
const out = new Int16Array(buffer.length);
for (let i = 0; i < buffer.length; i++) out[i] = MU_LAW_TABLE[buffer[i]];
return out;
}
function calculateRMS(pcm) {
let sum = 0;
for (let i = 0; i < pcm.length; i++) sum += pcm[i] * pcm[i];
return Math.sqrt(sum / pcm.length);
}
const sendTelnyxCmd = async (callId, action, body = {}) => {
try {
const url = callId ? `https://api.telnyx.com/v2/calls/${callId}/actions/${action}` : `https://api.telnyx.com/v2/calls`;
const response = await fetch(url, {
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
body: JSON.stringify(body)
});
if (!response.ok) log("TELNYX CMD ERROR", `[${action}] ${await response.text()}`);
return response;
} catch (e) { log("TELNYX CMD NETWORK ERROR", e.message); }
};
app.post("/api/call-action", async (req, res) => {
const { call_control_id, action, lead_id } = req.body;
if (!pendingCalls.has(call_control_id)) return res.status(404).json({ error: "Call not pending" });
if (lead_id) activeLeadIds.set(call_control_id, lead_id);
pendingCalls.set(call_control_id, action);
if (action === "reject") {
await sendTelnyxCmd(call_control_id, "hangup");
pendingCalls.delete(call_control_id);
} else {
await sendTelnyxCmd(call_control_id, "answer");
}
res.json({ ok: true });
});
app.post("/api/hangup", async (req, res) => {
await sendTelnyxCmd(req.body.call_control_id, "hangup");
res.json({ ok: true });
});
app.post("/api/send-sms", async (req, res) => {
log("SMS", `Attempting outbound SMS to ${req.body.to}...`);
try {
const sendRequest = async (fromNumber) => {
const payload = { from: fromNumber, to: req.body.to, text: req.body.text };
if (TELNYX_MESSAGING_PROFILE_ID) payload.messaging_profile_id = TELNYX_MESSAGING_PROFILE_ID;
return await fetch("https://api.telnyx.com/v2/messages", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` }, body: JSON.stringify(payload) });
};
let response = await sendRequest(TELNYX_ALPHA_SENDER);
let data = await response.json();
if (!response.ok && data.errors && data.errors.some(e => e.code === '40305' || e.code === '40012')) {
log("SMS WARNING", `Alpha Sender rejected. Falling back to standard US number ${TELNYX_FROM_NUMBER}...`);
response = await sendRequest(TELNYX_FROM_NUMBER);
data = await response.json();
}
if (!response.ok) log("SMS ERROR", JSON.stringify(data));
else log("SMS SUCCESS", `Message accepted by Telnyx for delivery to ${req.body.to}`);
res.json(data);
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post("/dial", async (req, res) => {
const { to, lead_id, action = "manual" } = req.body;
try {
let callPayload = { connection_id: TELNYX_CONNECTION_ID, from: TELNYX_FROM_NUMBER };
if (action === "manual") {
log("DIAL", `[Manual] Dialing Agent First (${MY_CELL_NUMBER})...`);
// We dial YOU first. We pass the customer's number securely in the client_state.
const stateObj = { type: "agent_first", customer_number: to, lead_id: lead_id };
callPayload.to = MY_CELL_NUMBER;
callPayload.client_state = Buffer.from(JSON.stringify(stateObj)).toString("base64");
} else {
log("DIAL", `[AI] Dialing Customer directly (${to})...`);
// Normal AI dialing directly to customer
callPayload.to = to;
}
const response = await fetch("https://api.telnyx.com/v2/calls", {
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
body: JSON.stringify(callPayload)
});
const data = await response.json();
if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
const ccid = data.data.call_control_id;
pendingCalls.set(ccid, action);
if (lead_id) activeLeadIds.set(ccid, lead_id);
await fetch(`${AI_SERVER_URL}/api/log-outbound`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ call_control_id: ccid, action, lead_id })
}).catch(() => {});
res.json({ ok: true, call_control_id: ccid });
} catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
app.post("/webhook/call", async (req, res) => {
res.sendStatus(200);
const type = req.body?.data?.event_type;
const payload = req.body?.data?.payload;
const callId = payload?.call_control_id;
if (!type) return;
switch (type) {
case "call.initiated":
if (payload?.direction === "incoming") {
const fromNumber = payload.from;
const now = Date.now();
if (incomingCallDebounce.has(fromNumber) && now - incomingCallDebounce.get(fromNumber) < 10000) return;
incomingCallDebounce.set(fromNumber, now);
log("INCOMING", `From: ${fromNumber}`);
pendingCalls.set(callId, "waiting");
await fetch(`${AI_SERVER_URL}/api/incoming-call`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ call_control_id: callId, from: fromNumber }) }).catch(()=>{});
}
break;
case "call.answered":
// 1. CHECK IF THIS IS A BRIDGING STATE (Agent picked up, or Customer picked up)
if (payload?.client_state) {
try {
const stateStr = Buffer.from(payload.client_state, "base64").toString("utf8");
const stateObj = JSON.parse(stateStr);
// SCENARIO A: Agent just answered their cell phone.
if (stateObj.type === "agent_first") {
log("BRIDGING", `Agent picked up! Dialing Customer (${stateObj.customer_number})...`);
// Start recording the agent leg so we capture the whole conversation
await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
// Play ringing to the AGENT so they know the customer is being called
await sendTelnyxCmd(callId, "playback_start", { audio_url: "https://actions.telnyx.com/sample-assets/ringback.wav", target_legs: "self" });
// Now dial the CUSTOMER, passing the Agent's call ID forward
const nextState = { type: "customer_answered", leg_a_id: callId };
await sendTelnyxCmd(null, "dial", {
to: stateObj.customer_number,
from: TELNYX_FROM_NUMBER,
connection_id: TELNYX_CONNECTION_ID,
client_state: Buffer.from(JSON.stringify(nextState)).toString("base64")
});
return;
}
// SCENARIO B: Customer just answered the phone.
if (stateObj.type === "customer_answered") {
log("BRIDGING", `Customer picked up! Bridging them to the Agent...`);
// Stop the ringing in the Agent's ear
await sendTelnyxCmd(stateObj.leg_a_id, "playback_stop");
// Bridge the Customer (callId) to the Agent (leg_a_id)
await sendTelnyxCmd(callId, "bridge", { call_control_id: stateObj.leg_a_id });
return;
}
} catch (e) { log("STATE ERROR", "Failed to parse client state JSON."); }
}
// 2. IF NOT BRIDGING, IT IS AN AI CALL OR INCOMING CALL
const actionType = pendingCalls.get(callId) || "ai";
if (actionType === "ai") {
log("AI CONNECTING", `Starting stream for ${callId}`);
await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
await sendTelnyxCmd(callId, "playback_start", { audio_url: `http://${req.headers.host}/ambience.mp3`, target_legs: "caller", loop: "infinity" });
await sendTelnyxCmd(callId, "streaming_start", { stream_url: `wss://${req.headers.host}/audio-stream`, stream_track: "inbound_track" });
await sendTelnyxCmd(callId, "transcription_start", { transcription_engine: "Telnyx", transcription_tracks: "inbound" });
setTimeout(async () => { log("HANGUP", `Enforcing 90s limit on ${callId}`); await sendTelnyxCmd(callId, "hangup"); }, 90000);
}
break;
case "call.transcription":
if (!payload?.transcription_data?.is_final) break;
try {
const aiRes = await fetch(`${AI_SERVER_URL}/api/process-text-turn`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: payload.transcription_data.transcript.trim(), call_id: callId, lead_id: activeLeadIds.get(callId) || null }) });
if (aiRes.ok) {
const aiData = await aiRes.json();
if (aiData.speak_url) await sendTelnyxCmd(callId, "playback_start", { audio_url: aiData.speak_url, target_legs: "self" });
}
} catch (e) { log("AI_SERVER_ERROR", e.message); }
break;
case "call.recording.saved":
if (payload.recording_urls?.mp3) {
await fetch(`${AI_SERVER_URL}/api/save-recording`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ call_control_id: callId, recording_url: payload.recording_urls.mp3 })
}).then(async (r) => { if (!r.ok) log("RECORDING ERROR", `AI Server status: ${r.status}`); }).catch((err) => log("RECORDING NET ERROR", err.message));
}
break;
case "call.hangup":
log("HANGUP", `Call ended. Reason: ${payload.hangup_cause}`);
pendingCalls.delete(callId);
activeLeadIds.delete(callId);
await fetch(`${AI_SERVER_URL}/api/call-ended`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ call_control_id: callId }) }).catch(() => {});
break;
}
});
wss.on("connection", (ws) => {
let isSpeaking = false;
let silenceFrames = 0;
let callControlId = null;
let hasPlayedFiller = false;
let hasGreeted = false;
let totalNoiseFrames = 0;
ws.on("message", async (raw) => {
try {
const msg = JSON.parse(raw);
if (msg.event === "start") {
callControlId = msg.start?.call_control_id;
setTimeout(async () => {
if (hasGreeted || !callControlId) return;
hasGreeted = true;
const greetingPhrase = "Hi, this is Nathan.";
await sendTelnyxCmd(callControlId, "playback_start", { audio_url: `${AI_SERVER_URL}/play-audio?voice=${ACTIVE_VOICE}&phrase=${encodeURIComponent(greetingPhrase)}`, target_legs: "self" });
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(() => {});
}, 1500);
}
if (msg.event === "media") {
const pcm16 = decodeMuLaw(Buffer.from(msg.media.payload, "base64"));
const rms = calculateRMS(pcm16);
if (rms > 500) {
hasGreeted = true;
silenceFrames = 0;
totalNoiseFrames++;
if (!isSpeaking) {
isSpeaking = true;
hasPlayedFiller = false;
await sendTelnyxCmd(callControlId, "playback_stop");
}
} else if (isSpeaking) {
silenceFrames++;
if (silenceFrames > 35 && !hasPlayedFiller) {
hasPlayedFiller = true;
isSpeaking = false;
if (totalNoiseFrames > 40) {
const fillers = ["Right.", "Got it.", "Okay.", "Yeah.", "Ah, okay."];
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(() => {});
}
totalNoiseFrames = 0;
}
}
}
} catch {}
});
});
server.listen(PORT, "0.0.0.0", () => {
log("STARTUP", `Telecom routing server running on port ${PORT}`);
});