Pepguy commited on
Commit
b97f4e8
Β·
verified Β·
1 Parent(s): 2ede20b

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +114 -120
app.js CHANGED
@@ -1,11 +1,9 @@
1
  import express from "express";
2
  import { createServer } from "http";
3
- import { WebSocketServer } from "ws";
4
  import { randomUUID } from "crypto";
5
 
6
  const app = express();
7
  const server = createServer(app);
8
- const wss = new WebSocketServer({ server, path: "/audio-stream" });
9
 
10
  app.use(express.static("public", { index: false }));
11
  app.use(express.json());
@@ -17,10 +15,7 @@ const TELNYX_CONNECTION_ID = process.env.TELNYX_CONNECTION_ID;
17
  const TELNYX_FROM_NUMBER = process.env.TELNYX_FROM_NUMBER;
18
  const AI_SERVER_URL = process.env.AI_SERVER_URL;
19
 
20
- const log = (tag, ...args) => {
21
- const time = new Date().toISOString();
22
- console.log(`[${time}] [${tag}]`, ...args);
23
- };
24
 
25
  let callLogs = [];
26
 
@@ -76,7 +71,7 @@ app.get("/", (req, res) => {
76
  body: JSON.stringify({ call_control_id: callControlId })
77
  });
78
  const data = await res.json();
79
- setStatus(data.ok ? "Hung up successfully." : "Error: " + data.error);
80
  fetchLogs();
81
  };
82
 
@@ -96,13 +91,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
  <span className={\`px-2 py-1 rounded text-xs \${log.status === 'completed' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'}\`}>
108
  {log.status}
@@ -113,9 +111,7 @@ app.get("/", (req, res) => {
113
  <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">
114
  Force Hang Up
115
  </button>
116
- ) : (
117
- <span className="text-gray-500 text-xs">Ended</span>
118
- )}
119
  </td>
120
  <td className="p-4">
121
  {log.recording_url ?
@@ -125,7 +121,7 @@ app.get("/", (req, res) => {
125
  </td>
126
  </tr>
127
  ))}
128
- {logs.length === 0 && <tr><td colSpan="5" className="p-4 text-center text-gray-500">No calls registered yet</td></tr>}
129
  </tbody>
130
  </table>
131
  </div>
@@ -142,6 +138,7 @@ app.get("/", (req, res) => {
142
  app.get("/api/logs", (req, res) => res.json([...callLogs].reverse()));
143
  app.get("/health", (req, res) => res.json({ status: "ok", uptime: process.uptime() }));
144
 
 
145
  app.get("/play-recording/:call_control_id", async (req, res) => {
146
  const logEntry = callLogs.find(l => l.call_control_id === req.params.call_control_id);
147
  if (!logEntry || !logEntry.raw_telnyx_url) return res.sendStatus(404);
@@ -149,10 +146,7 @@ app.get("/play-recording/:call_control_id", async (req, res) => {
149
  try {
150
  const s3Res = await fetch(logEntry.raw_telnyx_url);
151
  if (!s3Res.ok) return res.sendStatus(s3Res.status);
152
-
153
- const arrayBuffer = await s3Res.arrayBuffer();
154
- const buffer = Buffer.from(arrayBuffer);
155
-
156
  res.setHeader("Content-Type", "audio/mpeg");
157
  res.setHeader("Content-Length", buffer.length);
158
  res.send(buffer);
@@ -182,11 +176,9 @@ app.post("/dial", async (req, res) => {
182
  const data = await response.json();
183
  if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
184
 
185
- const callControlId = data.data.call_control_id;
186
-
187
  callLogs.push({
188
  id: randomUUID(),
189
- call_control_id: callControlId,
190
  target_number: to,
191
  voice_used: voice,
192
  status: "dialing",
@@ -202,16 +194,14 @@ app.post("/dial", async (req, res) => {
202
  }
203
  });
204
 
205
- // ─── MANUAL HANG UP ROUTE ──────────────────────────
206
  app.post("/call/hangup", async (req, res) => {
207
  const { call_control_id } = req.body;
208
  if (!call_control_id) return res.status(400).json({ error: "Missing call_control_id" });
209
 
210
  try {
211
  await fetch(`https://api.telnyx.com/v2/calls/${call_control_id}/actions/hangup`, {
212
- method: "POST",
213
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
214
- body: JSON.stringify({}),
215
  });
216
  res.json({ ok: true });
217
  } catch (err) {
@@ -219,26 +209,38 @@ app.post("/call/hangup", async (req, res) => {
219
  }
220
  });
221
 
222
- // ─── TELNYX WEBHOOK (Turn Detection & Alerts) ────────────────
223
  app.post("/webhook/call", async (req, res) => {
224
  const type = req.body?.data?.event_type;
225
  const payload = req.body?.data?.payload;
226
  const callId = payload?.call_control_id;
227
- const host = `https://${req.headers.host}`;
228
 
229
  if (!type) return res.sendStatus(200);
230
 
 
231
  const telnyxCmd = async (action, body = {}) => {
 
 
 
232
  try {
 
233
  const response = await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/${action}`, {
234
  method: "POST",
235
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
236
- body: JSON.stringify(body)
 
237
  });
238
- if (!response.ok) log("TELNYX CMD ERROR", `[${action}] failed (${response.status})`);
 
 
 
 
 
 
239
  return response;
240
  } catch (e) {
241
- log("TELNYX CMD NETWORK ERROR", e.message);
 
242
  }
243
  };
244
 
@@ -252,106 +254,98 @@ app.post("/webhook/call", async (req, res) => {
252
  return entry ? entry.voice_used : "Algieba";
253
  };
254
 
255
- switch (type) {
256
- case "call.initiated":
257
- log("INITIATED", `from=${payload?.from} to=${payload?.to}`);
258
- if (payload?.direction === "incoming") {
259
- log("INBOUND", "Answering inbound call & alerting AI Server...");
260
-
261
- // ── NEW: Alert AI Server so human can pick up ──
262
- fetch(`${AI_SERVER_URL}/api/inbound-call`, {
263
- method: "POST", headers: { "Content-Type": "application/json" },
264
- body: JSON.stringify({ from: payload?.from, to: payload?.to, call_id: callId })
265
- }).catch(() => {});
266
-
267
- callLogs.push({
268
- id: randomUUID(),
269
- call_control_id: callId,
270
- target_number: payload?.from,
271
- voice_used: "Algieba",
272
- status: "in-progress",
273
- last_transcript: "",
274
- recording_url: null,
275
- raw_telnyx_url: null,
276
- created_at: new Date().toISOString()
277
- });
278
- await telnyxCmd("answer", {});
279
- }
280
- break;
281
-
282
- case "call.answered":
283
- log("ANSWERED", `Call ${callId} connected.`);
284
- updateLog({ status: "in-progress" });
285
 
286
- await telnyxCmd("record_start", { format: "mp3", channels: "dual", play_beep: false });
287
-
288
- await telnyxCmd("playback_start", {
289
- audio_url: `${host}/ambience.mp3`,
290
- loop: "infinity",
291
- target_legs: "self"
292
- });
293
 
294
- await telnyxCmd("transcription_start", {
295
- transcription_engine: "Telnyx",
296
- transcription_tracks: "inbound"
297
- });
298
 
299
- // ── FIX: Audio Playback URL uses Query Params ──
300
- setTimeout(async () => {
301
- const voice = getActiveVoice();
302
- const phrase = encodeURIComponent("am I speaking with the business owner?");
303
- await telnyxCmd("playback_start", {
304
- audio_url: `${AI_SERVER_URL}/play-audio?voice=${voice}&phrase=${phrase}`,
305
- target_legs: "self"
306
  });
307
- }, 1500);
308
-
309
- setTimeout(async () => {
310
- await telnyxCmd("hangup", {});
311
- }, 60000);
312
- break;
313
-
314
- case "call.transcription":
315
- const data = payload?.transcription_data;
316
- if (data && data.is_final) {
317
- const transcript = data.transcript;
318
- const voice = getActiveVoice();
319
- log("TRANSCRIPT", `User said: "${transcript}"`);
320
- updateLog({ last_transcript: transcript });
321
-
322
- try {
323
- const aiRes = await fetch(`${AI_SERVER_URL}/api/process-text-turn`, {
324
- method: "POST",
325
- headers: { "Content-Type": "application/json" },
326
- body: JSON.stringify({ text: transcript, voice: voice })
327
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
 
329
- if (aiRes.ok) {
330
- const aiData = await aiRes.json();
331
- if (aiData.speak_url) {
332
- log("AI_REPLY", `Instructing Telnyx to play generated reply.`);
333
- await telnyxCmd("playback_start", {
334
- audio_url: aiData.speak_url,
335
- target_legs: "self"
336
- });
 
337
  }
338
- }
339
- } catch (e) { log("AI_SERVER_ERROR", e.message); }
340
- }
341
- break;
342
-
343
- case "call.recording.saved":
344
- updateLog({
345
- status: "completed",
346
- raw_telnyx_url: payload.recording_urls.mp3,
347
- recording_url: `/play-recording/${callId}`
348
- });
349
- break;
350
 
351
- case "call.hangup":
352
- updateLog({ status: "completed" });
353
- log("CALL ENDED", `Call ${callId} terminated.`);
354
- break;
 
 
 
355
  }
356
 
357
  res.sendStatus(200);
 
1
  import express from "express";
2
  import { createServer } from "http";
 
3
  import { randomUUID } from "crypto";
4
 
5
  const app = express();
6
  const server = createServer(app);
 
7
 
8
  app.use(express.static("public", { index: false }));
9
  app.use(express.json());
 
15
  const TELNYX_FROM_NUMBER = process.env.TELNYX_FROM_NUMBER;
16
  const AI_SERVER_URL = process.env.AI_SERVER_URL;
17
 
18
+ const log = (tag, ...args) => console.log(`[${new Date().toISOString()}] [${tag}]`, ...args);
 
 
 
19
 
20
  let callLogs = [];
21
 
 
71
  body: JSON.stringify({ call_control_id: callControlId })
72
  });
73
  const data = await res.json();
74
+ setStatus(data.ok ? "Hung up successfully." : "Error hanging up.");
75
  fetchLogs();
76
  };
77
 
 
91
  <div className="bg-gray-800 rounded-lg overflow-hidden shadow-lg">
92
  <table className="w-full text-left">
93
  <thead className="bg-gray-900 text-gray-400 text-sm">
94
+ <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>
95
  </thead>
96
  <tbody className="divide-y divide-gray-700">
97
  {logs.map(log => (
98
  <tr key={log.id}>
99
  <td className="p-4 text-xs font-mono text-gray-500">{log.call_control_id.slice(0,8)}...</td>
100
  <td className="p-4 font-mono">{log.target_number}</td>
101
+ <td className="p-4 text-xs text-gray-300 italic truncate max-w-[200px]" title={log.last_transcript}>
102
+ {log.last_transcript || "..."}
103
+ </td>
104
  <td className="p-4">
105
  <span className={\`px-2 py-1 rounded text-xs \${log.status === 'completed' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'}\`}>
106
  {log.status}
 
111
  <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">
112
  Force Hang Up
113
  </button>
114
+ ) : <span className="text-gray-500 text-xs">Ended</span>}
 
 
115
  </td>
116
  <td className="p-4">
117
  {log.recording_url ?
 
121
  </td>
122
  </tr>
123
  ))}
124
+ {logs.length === 0 && <tr><td colSpan="6" className="p-4 text-center text-gray-500">No calls registered yet</td></tr>}
125
  </tbody>
126
  </table>
127
  </div>
 
138
  app.get("/api/logs", (req, res) => res.json([...callLogs].reverse()));
139
  app.get("/health", (req, res) => res.json({ status: "ok", uptime: process.uptime() }));
140
 
141
+ // ─── PROXY FOR TELNYX RECORDINGS ─────────────
142
  app.get("/play-recording/:call_control_id", async (req, res) => {
143
  const logEntry = callLogs.find(l => l.call_control_id === req.params.call_control_id);
144
  if (!logEntry || !logEntry.raw_telnyx_url) return res.sendStatus(404);
 
146
  try {
147
  const s3Res = await fetch(logEntry.raw_telnyx_url);
148
  if (!s3Res.ok) return res.sendStatus(s3Res.status);
149
+ const buffer = Buffer.from(await s3Res.arrayBuffer());
 
 
 
150
  res.setHeader("Content-Type", "audio/mpeg");
151
  res.setHeader("Content-Length", buffer.length);
152
  res.send(buffer);
 
176
  const data = await response.json();
177
  if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
178
 
 
 
179
  callLogs.push({
180
  id: randomUUID(),
181
+ call_control_id: data.data.call_control_id,
182
  target_number: to,
183
  voice_used: voice,
184
  status: "dialing",
 
194
  }
195
  });
196
 
197
+ // ─── MANUAL HANG UP ──────────────────────────
198
  app.post("/call/hangup", async (req, res) => {
199
  const { call_control_id } = req.body;
200
  if (!call_control_id) return res.status(400).json({ error: "Missing call_control_id" });
201
 
202
  try {
203
  await fetch(`https://api.telnyx.com/v2/calls/${call_control_id}/actions/hangup`, {
204
+ method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` }, body: JSON.stringify({}),
 
 
205
  });
206
  res.json({ ok: true });
207
  } catch (err) {
 
209
  }
210
  });
211
 
212
+ // ─── TELNYX WEBHOOK (With Timeout Safety) ────
213
  app.post("/webhook/call", async (req, res) => {
214
  const type = req.body?.data?.event_type;
215
  const payload = req.body?.data?.payload;
216
  const callId = payload?.call_control_id;
 
217
 
218
  if (!type) return res.sendStatus(200);
219
 
220
+ // ── FIX: AbortController prevents fetch from hanging indefinitely ──
221
  const telnyxCmd = async (action, body = {}) => {
222
+ const controller = new AbortController();
223
+ const timeout = setTimeout(() => controller.abort(), 5000); // 5 second hard timeout
224
+
225
  try {
226
+ log("TELNYX CMD DISPATCH", `Sending [${action}]...`);
227
  const response = await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/${action}`, {
228
  method: "POST",
229
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
230
+ body: JSON.stringify(body),
231
+ signal: controller.signal
232
  });
233
+ clearTimeout(timeout);
234
+
235
+ if (!response.ok) {
236
+ log("TELNYX CMD ERROR", `[${action}] failed (${response.status}): ${await response.text()}`);
237
+ } else {
238
+ log("TELNYX CMD SUCCESS", `[${action}] accepted.`);
239
+ }
240
  return response;
241
  } catch (e) {
242
+ clearTimeout(timeout);
243
+ log("TELNYX CMD NETWORK ERROR", `[${action}] failed: ${e.message}`);
244
  }
245
  };
246
 
 
254
  return entry ? entry.voice_used : "Algieba";
255
  };
256
 
257
+ try {
258
+ switch (type) {
259
+ case "call.initiated":
260
+ log("INITIATED", `from=${payload?.from} to=${payload?.to}`);
261
+ if (payload?.direction === "incoming") {
262
+ log("INBOUND", "Answering inbound call...");
263
+ fetch(`${AI_SERVER_URL}/api/inbound-call`, {
264
+ method: "POST", headers: { "Content-Type": "application/json" },
265
+ body: JSON.stringify({ from: payload?.from, to: payload?.to, call_id: callId })
266
+ }).catch(() => {});
267
+
268
+ callLogs.push({
269
+ id: randomUUID(), call_control_id: callId, target_number: payload?.from, voice_used: "Algieba",
270
+ status: "in-progress", last_transcript: "", recording_url: null, raw_telnyx_url: null, created_at: new Date().toISOString()
271
+ });
272
+ await telnyxCmd("answer", {});
273
+ }
274
+ break;
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
+ case "call.answered":
277
+ log("ANSWERED", `Call ${callId} connected.`);
278
+ updateLog({ status: "in-progress" });
 
 
 
 
279
 
280
+ await telnyxCmd("record_start", { format: "mp3", channels: "dual", play_beep: false });
281
+
282
+ // Removed Ambience Playback entirely per your request
 
283
 
284
+ await telnyxCmd("transcription_start", {
285
+ transcription_engine: "Telnyx",
286
+ transcription_tracks: "inbound"
 
 
 
 
287
  });
288
+
289
+ setTimeout(async () => {
290
+ const voice = getActiveVoice();
291
+ const phrase = encodeURIComponent("am I speaking with the business owner?");
292
+ await telnyxCmd("playback_start", {
293
+ audio_url: `${AI_SERVER_URL}/play-audio?voice=${voice}&phrase=${phrase}`,
294
+ target_legs: "self"
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  });
296
+ }, 1500);
297
+
298
+ setTimeout(async () => {
299
+ log("HANGUP", `Enforcing 60-second limit on call ${callId}`);
300
+ await telnyxCmd("hangup", {});
301
+ }, 60000);
302
+ break;
303
+
304
+ case "call.transcription":
305
+ const data = payload?.transcription_data;
306
+ if (data && data.is_final) {
307
+ const transcript = data.transcript;
308
+ const voice = getActiveVoice();
309
+ log("TRANSCRIPT", `User said: "${transcript}"`);
310
+ updateLog({ last_transcript: transcript });
311
+
312
+ try {
313
+ const aiRes = await fetch(`${AI_SERVER_URL}/api/process-text-turn`, {
314
+ method: "POST",
315
+ headers: { "Content-Type": "application/json" },
316
+ body: JSON.stringify({ text: transcript, voice: voice })
317
+ });
318
 
319
+ if (aiRes.ok) {
320
+ const aiData = await aiRes.json();
321
+ if (aiData.speak_url) {
322
+ log("AI_REPLY", `Instructing Telnyx to play generated reply.`);
323
+ await telnyxCmd("playback_start", {
324
+ audio_url: aiData.speak_url,
325
+ target_legs: "self"
326
+ });
327
+ }
328
  }
329
+ } catch (e) { log("AI_SERVER_ERROR", e.message); }
330
+ }
331
+ break;
332
+
333
+ case "call.recording.saved":
334
+ log("RECORDING", `Saved. URL: ${payload.recording_urls.mp3}`);
335
+ updateLog({
336
+ status: "completed",
337
+ raw_telnyx_url: payload.recording_urls.mp3,
338
+ recording_url: `/play-recording/${callId}`
339
+ });
340
+ break;
341
 
342
+ case "call.hangup":
343
+ updateLog({ status: "completed" });
344
+ log("CALL ENDED", `Call terminated.`);
345
+ break;
346
+ }
347
+ } catch (fatalError) {
348
+ log("WEBHOOK FATAL ERROR", fatalError.message);
349
  }
350
 
351
  res.sendStatus(200);