neoagent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.env.example +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +42 -0
  4. package/bin/neoagent.js +8 -0
  5. package/com.neoagent.plist +45 -0
  6. package/docs/configuration.md +45 -0
  7. package/docs/skills.md +45 -0
  8. package/lib/manager.js +459 -0
  9. package/package.json +61 -0
  10. package/server/db/database.js +239 -0
  11. package/server/index.js +442 -0
  12. package/server/middleware/auth.js +35 -0
  13. package/server/public/app.html +559 -0
  14. package/server/public/css/app.css +608 -0
  15. package/server/public/css/styles.css +472 -0
  16. package/server/public/favicon.svg +17 -0
  17. package/server/public/js/app.js +3283 -0
  18. package/server/public/login.html +313 -0
  19. package/server/routes/agents.js +125 -0
  20. package/server/routes/auth.js +105 -0
  21. package/server/routes/browser.js +116 -0
  22. package/server/routes/mcp.js +164 -0
  23. package/server/routes/memory.js +193 -0
  24. package/server/routes/messaging.js +153 -0
  25. package/server/routes/protocols.js +87 -0
  26. package/server/routes/scheduler.js +63 -0
  27. package/server/routes/settings.js +98 -0
  28. package/server/routes/skills.js +107 -0
  29. package/server/routes/store.js +1192 -0
  30. package/server/services/ai/compaction.js +82 -0
  31. package/server/services/ai/engine.js +1690 -0
  32. package/server/services/ai/models.js +46 -0
  33. package/server/services/ai/multiStep.js +112 -0
  34. package/server/services/ai/providers/anthropic.js +181 -0
  35. package/server/services/ai/providers/base.js +40 -0
  36. package/server/services/ai/providers/google.js +187 -0
  37. package/server/services/ai/providers/grok.js +121 -0
  38. package/server/services/ai/providers/ollama.js +162 -0
  39. package/server/services/ai/providers/openai.js +167 -0
  40. package/server/services/ai/toolRunner.js +218 -0
  41. package/server/services/browser/controller.js +320 -0
  42. package/server/services/cli/executor.js +204 -0
  43. package/server/services/mcp/client.js +260 -0
  44. package/server/services/memory/embeddings.js +126 -0
  45. package/server/services/memory/manager.js +431 -0
  46. package/server/services/messaging/base.js +23 -0
  47. package/server/services/messaging/discord.js +238 -0
  48. package/server/services/messaging/manager.js +328 -0
  49. package/server/services/messaging/telegram.js +243 -0
  50. package/server/services/messaging/telnyx.js +693 -0
  51. package/server/services/messaging/whatsapp.js +304 -0
  52. package/server/services/scheduler/cron.js +312 -0
  53. package/server/services/websocket.js +191 -0
  54. package/server/utils/security.js +71 -0
@@ -0,0 +1,693 @@
1
+ 'use strict';
2
+
3
+ const { BasePlatform } = require('./base');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const https = require('https');
7
+ const { OpenAI } = require('openai');
8
+
9
+ const AUDIO_DIR = path.join(__dirname, '..', '..', '..', 'data', 'telnyx-audio');
10
+ const RECORDING_TURN_LIMIT_MS = 4000; // auto-stop recording after 4 s of silence
11
+
12
+ class TelnyxVoicePlatform extends BasePlatform {
13
+ constructor(config = {}) {
14
+ super('telnyx', config);
15
+ this.supportsVoice = true;
16
+
17
+ // Config fields set via the web UI connect modal
18
+ this.apiKey = config.apiKey || '';
19
+ this.phoneNumber = config.phoneNumber || '';
20
+ this.connectionId = config.connectionId || '';
21
+ this.webhookUrl = config.webhookUrl || ''; // e.g. https://xyz.ngrok.io
22
+ this.ttsVoice = config.ttsVoice || 'alloy';
23
+ this.ttsModel = config.ttsModel || 'tts-1';
24
+ this.sttModel = config.sttModel || 'whisper-1';
25
+
26
+ // Allowed-numbers whitelist (empty = allow all)
27
+ this.allowedNumbers = Array.isArray(config.allowedNumbers) ? config.allowedNumbers : [];
28
+
29
+ // Secret code for non-whitelisted inbound callers (digits only; empty = feature disabled)
30
+ this.voiceSecret = String(config.voiceSecret || '').replace(/\D/g, '');
31
+
32
+ // Runtime state
33
+ this._sessions = new Map(); // ccId → session object
34
+ this._recordingTimers = new Map(); // ccId → setTimeout handle
35
+ this._secretTimers = new Map(); // ccId → secret-entry timeout handle
36
+ this._bannedNumbers = new Map(); // normalizedNumber → ban expiry timestamp
37
+ this._client = null; // Telnyx SDK instance
38
+ this._openai = null; // OpenAI client
39
+ this._webhookToken = null; // resolved at connect time from TELNYX_WEBHOOK_TOKEN
40
+ this._thinkAudioFile = null; // pre-cached "hold" audio filename
41
+ }
42
+
43
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
44
+
45
+ async connect() {
46
+ if (!this.apiKey || !this.phoneNumber || !this.connectionId || !this.webhookUrl) {
47
+ throw new Error('Telnyx Voice requires apiKey, phoneNumber, connectionId, and webhookUrl');
48
+ }
49
+
50
+ if (!fs.existsSync(AUDIO_DIR)) fs.mkdirSync(AUDIO_DIR, { recursive: true });
51
+
52
+ const TelnyxSDK = require('telnyx');
53
+ const TelnyxClient = TelnyxSDK.default || TelnyxSDK;
54
+ this._client = new TelnyxClient({ apiKey: this.apiKey });
55
+
56
+ // Resolve OpenAI key: env var → stored API_KEYS.json → none (fallback to Telnyx speak)
57
+ let openAiKey = process.env.OPENAI_API_KEY;
58
+ if (!openAiKey) {
59
+ try {
60
+ const keysPath = path.join(__dirname, '..', '..', '..', 'agent-data', 'API_KEYS.json');
61
+ const keys = JSON.parse(fs.readFileSync(keysPath, 'utf8'));
62
+ openAiKey = keys.OPENAI_API_KEY || keys.openai_api_key || keys.openai || null;
63
+ } catch { /* file missing or unreadable — fine */ }
64
+ }
65
+ if (openAiKey) {
66
+ this._openai = new OpenAI({ apiKey: openAiKey });
67
+ console.log('[TelnyxVoice] OpenAI TTS enabled');
68
+ } else {
69
+ console.warn('[TelnyxVoice] No OpenAI API key found — TTS will use Telnyx native speak (language auto-detected)');
70
+ }
71
+
72
+ // Derive the full inbound webhook URL (with token) so it can be logged / displayed
73
+ const token = process.env.TELNYX_WEBHOOK_TOKEN;
74
+ this._webhookToken = token || null;
75
+ const inboundUrl = `${this.webhookUrl}/api/telnyx/webhook${token ? `?token=${token}` : ''}`;
76
+ console.log(`[TelnyxVoice] Inbound webhook URL (configure this in the Telnyx portal): ${inboundUrl}`);
77
+
78
+ // Pre-generate the "thinking" hold audio so it's instant during calls
79
+ this._precacheThinkAudio();
80
+
81
+ this.status = 'connected';
82
+ this.emit('connected');
83
+ console.log(`[TelnyxVoice] Connected — phone: ${this.phoneNumber}`);
84
+ return { status: 'connected', inboundWebhookUrl: inboundUrl };
85
+ }
86
+
87
+ async disconnect() {
88
+ // Hang up any live calls
89
+ for (const [ccId] of this._sessions) {
90
+ try { await this._client.calls.actions.hangup(ccId); } catch {}
91
+ }
92
+ this._sessions.clear();
93
+ for (const t of this._recordingTimers.values()) clearTimeout(t);
94
+ this._recordingTimers.clear();
95
+ for (const t of this._secretTimers.values()) clearTimeout(t);
96
+ this._secretTimers.clear();
97
+ this.status = 'disconnected';
98
+ this.emit('disconnected', {});
99
+ }
100
+
101
+ async logout() {
102
+ await this.disconnect();
103
+ }
104
+
105
+ getStatus() { return this.status; }
106
+ getAuthInfo() { return { phoneNumber: this.phoneNumber }; }
107
+
108
+ // ── Whitelist management ────────────────────────────────────────────────────
109
+
110
+ setAllowedNumbers(numbers) {
111
+ this.allowedNumbers = Array.isArray(numbers) ? numbers : [];
112
+ console.log(`[TelnyxVoice] Whitelist updated: ${this.allowedNumbers.length} number(s)`);
113
+ }
114
+
115
+ setVoiceSecret(secret) {
116
+ this.voiceSecret = String(secret || '').replace(/\D/g, '');
117
+ console.log(`[TelnyxVoice] Voice secret updated (${this.voiceSecret.length} digit(s))`);
118
+ }
119
+
120
+ _isAllowed(number) {
121
+ if (!this.allowedNumbers || !this.allowedNumbers.length) return false;
122
+ const normalize = (n) => n.replace(/\D/g, '');
123
+ const cn = normalize(number);
124
+ return this.allowedNumbers.some(wl => {
125
+ const cw = normalize(wl);
126
+ return cn === cw || cn.endsWith(cw) || cw.endsWith(cn);
127
+ });
128
+ }
129
+
130
+ _normalizeNumber(n) {
131
+ return n.replace(/\D/g, '');
132
+ }
133
+
134
+ _isBanned(number) {
135
+ const key = this._normalizeNumber(number);
136
+ const expiry = this._bannedNumbers.get(key);
137
+ if (!expiry) return false;
138
+ if (Date.now() > expiry) {
139
+ this._bannedNumbers.delete(key);
140
+ return false;
141
+ }
142
+ return true;
143
+ }
144
+
145
+ _banNumber(number, durationMs = 10 * 60 * 1000) {
146
+ const key = this._normalizeNumber(number);
147
+ this._bannedNumbers.set(key, Date.now() + durationMs);
148
+ console.log(`[TelnyxVoice] Banned ${number} for ${durationMs / 60000} min`);
149
+ }
150
+
151
+ _startSecretTimer(ccId) {
152
+ this._cancelSecretTimer(ccId);
153
+ const t = setTimeout(async () => {
154
+ this._secretTimers.delete(ccId);
155
+ if (!this._hasSession(ccId)) return;
156
+ const sess = this._session(ccId);
157
+ if (!sess.awaitingSecret) return;
158
+ console.log(`[TelnyxVoice] Secret code timeout for ${ccId.slice(-8)} (${sess.callerNumber})`);
159
+ this._banNumber(sess.callerNumber);
160
+ this._endSession(ccId);
161
+ try { await this._hangupCall(ccId); } catch {}
162
+ }, 10000);
163
+ this._secretTimers.set(ccId, t);
164
+ }
165
+
166
+ _cancelSecretTimer(ccId) {
167
+ const t = this._secretTimers.get(ccId);
168
+ if (t) { clearTimeout(t); this._secretTimers.delete(ccId); }
169
+ }
170
+
171
+ // ── Session helpers ────────────────────────────────────────────────────────
172
+
173
+ _initSession(ccId, callerNumber = '') {
174
+ this._sessions.set(ccId, {
175
+ callerNumber,
176
+ isProcessing: false,
177
+ awaitingUserInput: false,
178
+ isThinking: false, // true while agent is processing — gates playback.ended mutations
179
+ replySent: false, // prevents double-reply within one agent turn
180
+ processedRecordings: new Set(),
181
+ // Secret-code gating (non-whitelisted callers)
182
+ awaitingSecret: false,
183
+ secretDigits: '',
184
+ });
185
+ }
186
+
187
+ _session(ccId) { return this._sessions.get(ccId); }
188
+ _hasSession(ccId) { return this._sessions.has(ccId); }
189
+
190
+ _endSession(ccId) {
191
+ this._sessions.delete(ccId);
192
+ this._cancelRecordingTimer(ccId);
193
+ this._cancelSecretTimer(ccId);
194
+ }
195
+
196
+ _scheduleRecordingStop(ccId) {
197
+ this._cancelRecordingTimer(ccId);
198
+ const t = setTimeout(async () => {
199
+ this._recordingTimers.delete(ccId);
200
+ if (!this._hasSession(ccId)) return;
201
+ console.log(`[TelnyxVoice] Auto-stopping recording for ${ccId}`);
202
+ try { await this._stopRecording(ccId); } catch {}
203
+ }, RECORDING_TURN_LIMIT_MS);
204
+ this._recordingTimers.set(ccId, t);
205
+ }
206
+
207
+ _cancelRecordingTimer(ccId) {
208
+ const t = this._recordingTimers.get(ccId);
209
+ if (t) { clearTimeout(t); this._recordingTimers.delete(ccId); }
210
+ }
211
+
212
+ // ── Telnyx call-control wrappers ───────────────────────────────────────────
213
+
214
+ _isTerminalError(err) {
215
+ const errs = (err.error?.errors) || err.errors ||
216
+ (err.raw?.errors) || (err.response?.data?.errors);
217
+ if (!errs) return false;
218
+ return errs.some(e => ['90018', '90053', '90055'].includes(String(e.code)));
219
+ }
220
+
221
+ async _answerCall(ccId) {
222
+ try { await this._client.calls.actions.answer(ccId); }
223
+ catch (err) { if (!this._isTerminalError(err)) throw err; }
224
+ }
225
+
226
+ async _rejectCall(ccId) {
227
+ try { await this._client.calls.actions.reject(ccId, { cause: 'CALL_REJECTED' }); } catch {}
228
+ }
229
+
230
+ async _hangupCall(ccId) {
231
+ try { await this._client.calls.actions.hangup(ccId); }
232
+ catch (err) { if (!this._isTerminalError(err)) throw err; }
233
+ }
234
+
235
+ async _playAudio(ccId, url, loop = false) {
236
+ try {
237
+ await this._client.calls.actions.startPlayback(ccId, {
238
+ audio_url: url,
239
+ loop: loop ? 'infinity' : 1,
240
+ });
241
+ } catch (err) { if (!this._isTerminalError(err)) throw err; }
242
+ }
243
+
244
+ async _stopAudio(ccId) {
245
+ try { await this._client.calls.actions.stopPlayback(ccId, {}); }
246
+ catch (err) { if (!this._isTerminalError(err)) throw err; }
247
+ }
248
+
249
+ async _startRecording(ccId) {
250
+ try {
251
+ await this._client.calls.actions.startRecording(ccId, {
252
+ format: 'mp3',
253
+ channels: 'single',
254
+ play_beep: false,
255
+ time_limit: 60,
256
+ });
257
+ } catch (err) { if (!this._isTerminalError(err)) throw err; }
258
+ }
259
+
260
+ async _stopRecording(ccId) {
261
+ try { await this._client.calls.actions.stopRecording(ccId, {}); }
262
+ catch (err) { if (!this._isTerminalError(err)) throw err; }
263
+ }
264
+
265
+ // ── Pre-cached think audio ─────────────────────────────────────────────────
266
+
267
+ async _precacheThinkAudio() {
268
+ if (!this._openai) return; // will use Telnyx speak fallback at playback time
269
+ try {
270
+ const file = `think_hold_${Date.now()}.mp3`;
271
+ const filePath = path.join(AUDIO_DIR, file);
272
+ const mp3 = await this._openai.audio.speech.create({
273
+ model: this.ttsModel,
274
+ voice: this.ttsVoice,
275
+ input: 'One moment please.',
276
+ });
277
+ const buf = Buffer.from(await mp3.arrayBuffer());
278
+ await fs.promises.writeFile(filePath, buf);
279
+ this._thinkAudioFile = file;
280
+ console.log('[TelnyxVoice] Think audio pre-cached');
281
+ } catch (err) {
282
+ console.warn(`[TelnyxVoice] Failed to pre-cache think audio: ${err.message}`);
283
+ }
284
+ }
285
+
286
+ // Play the pre-cached hold phrase (instant) or fall back to Telnyx speak.
287
+ async _playThinkAudio(ccId) {
288
+ if (this._thinkAudioFile) {
289
+ try {
290
+ await this._playAudio(ccId, this._publicUrl(this._thinkAudioFile));
291
+ return;
292
+ } catch (err) {
293
+ console.warn(`[TelnyxVoice] Pre-cached think audio failed: ${err.message}`);
294
+ }
295
+ }
296
+ // Fallback: Telnyx native speak (still fast — no file gen needed)
297
+ try {
298
+ await this._client.calls.actions.speak(ccId, {
299
+ payload: 'One moment please.',
300
+ voice: 'female',
301
+ language: 'en-US',
302
+ });
303
+ } catch (err) {
304
+ if (!this._isTerminalError(err)) console.error('[TelnyxVoice] Think speak failed:', err.message);
305
+ }
306
+ }
307
+
308
+ // ── OpenAI TTS / STT ───────────────────────────────────────────────────────
309
+
310
+ async _tts(text, destPath) {
311
+ const mp3 = await this._openai.audio.speech.create({
312
+ model: this.ttsModel,
313
+ voice: this.ttsVoice,
314
+ input: text,
315
+ });
316
+ const buf = Buffer.from(await mp3.arrayBuffer());
317
+ await fs.promises.writeFile(destPath, buf);
318
+ }
319
+
320
+ // Say text on a call — tries OpenAI TTS+hosted audio first, falls back to
321
+ // Telnyx native speak (no external hosting or OpenAI key required).
322
+ async _sayText(ccId, text) {
323
+ if (this._openai) {
324
+ try {
325
+ const file = this._tmpFile('say', ccId);
326
+ const filePath = path.join(AUDIO_DIR, file);
327
+ await this._tts(text, filePath);
328
+ await this._playAudio(ccId, this._publicUrl(file));
329
+ setTimeout(() => fs.unlink(filePath, () => {}), 60000);
330
+ return;
331
+ } catch (err) {
332
+ console.warn(`[TelnyxVoice] OpenAI TTS failed (${err.message}), falling back to Telnyx speak`);
333
+ }
334
+ }
335
+ // Telnyx native speak fallback
336
+ try {
337
+ const isGerman = /\b(ich|du|ist|und|der|die|das|nicht|ein|hallo|auf|danke|bitte|wie|was|wer|wo|warum|kann|haben|sein|noch|auch|mit|von|bei|nach|für)\b/i.test(text);
338
+ await this._client.calls.actions.speak(ccId, {
339
+ payload: text,
340
+ voice: 'female',
341
+ language: isGerman ? 'de-DE' : 'en-US',
342
+ });
343
+ } catch (speakErr) {
344
+ console.error(`[TelnyxVoice] Telnyx speak also failed: ${speakErr.message}`, speakErr?.error?.errors || '');
345
+ if (!this._isTerminalError(speakErr)) throw speakErr;
346
+ }
347
+ }
348
+
349
+ async _stt(filePath) {
350
+ try {
351
+ const t = await this._openai.audio.transcriptions.create({
352
+ file: fs.createReadStream(filePath),
353
+ model: this.sttModel,
354
+ });
355
+ return t.text;
356
+ } catch (err) {
357
+ console.error('[TelnyxVoice] STT error:', err.message);
358
+ return '';
359
+ }
360
+ }
361
+
362
+ // ── File helpers ───────────────────────────────────────────────────────────
363
+
364
+ async _downloadRecording(url, dest) {
365
+ return new Promise((resolve, reject) => {
366
+ const file = fs.createWriteStream(dest);
367
+ https.get(url, (res) => {
368
+ if (res.statusCode !== 200) {
369
+ file.close();
370
+ return reject(new Error(`Download failed: ${res.statusCode}`));
371
+ }
372
+ res.pipe(file);
373
+ file.on('finish', () => file.close(resolve));
374
+ }).on('error', (err) => { fs.unlink(dest, () => {}); reject(err); });
375
+ });
376
+ }
377
+
378
+ _publicUrl(filename) {
379
+ return `${this.webhookUrl}/telnyx-audio/${filename}`;
380
+ }
381
+
382
+ _tmpFile(prefix, ccId) {
383
+ return `${prefix}_${ccId.replace(/[^a-zA-Z0-9]/g, '')}_${Date.now()}.mp3`;
384
+ }
385
+
386
+ // ── Main webhook handler ───────────────────────────────────────────────────
387
+ // Called by MessagingManager.handleTelnyxWebhook() from /api/telnyx/webhook
388
+
389
+ async handleWebhook(event) {
390
+ if (!event?.data?.event_type) return;
391
+ const { event_type: eventType, payload } = event.data;
392
+ const ccId = payload?.call_control_id;
393
+ if (!ccId) return;
394
+
395
+ // Ignore events for sessions we don't know about (except the ones that start one)
396
+ if (!this._hasSession(ccId) &&
397
+ eventType !== 'call.initiated' &&
398
+ eventType !== 'call.answered') {
399
+ return;
400
+ }
401
+
402
+ // Outbound call.initiated is handled by initiateCall() already
403
+ if (eventType === 'call.initiated' && payload.direction === 'outbound') return;
404
+
405
+ console.log(`[TelnyxVoice] ${eventType} — ccId=${ccId.slice(-8)}`);
406
+
407
+ try {
408
+ switch (eventType) {
409
+
410
+ // ── Inbound call received ───────────────────────────────────────────
411
+ case 'call.initiated': {
412
+ if (payload.direction !== 'incoming') break;
413
+ const caller = payload.from;
414
+ if (!this._isAllowed(caller)) {
415
+ // Check ban list first — banned callers are rejected immediately
416
+ if (this._isBanned(caller)) {
417
+ console.log(`[TelnyxVoice] Rejecting banned caller: ${caller}`);
418
+ await this._rejectCall(ccId);
419
+ this.emit('blocked_caller', { caller, ccId });
420
+ break;
421
+ }
422
+ // If no secret is configured, fall back to the old reject behaviour
423
+ if (!this.voiceSecret) {
424
+ console.log(`[TelnyxVoice] Blocked non-whitelisted caller (no secret set): ${caller}`);
425
+ await this._rejectCall(ccId);
426
+ this.emit('blocked_caller', { caller, ccId });
427
+ break;
428
+ }
429
+ // Secret configured — answer and wait silently for code entry
430
+ console.log(`[TelnyxVoice] Non-whitelisted caller ${caller} — awaiting secret code`);
431
+ this._initSession(ccId, caller);
432
+ this._session(ccId).awaitingSecret = true;
433
+ await this._answerCall(ccId);
434
+ break;
435
+ }
436
+ // Init session BEFORE answering so call.answered (which arrives as a
437
+ // separate concurrent webhook) always finds a valid session.
438
+ this._initSession(ccId, caller);
439
+ await this._answerCall(ccId);
440
+ console.log(`[TelnyxVoice] Answered inbound call from ${caller}`);
441
+ break;
442
+ }
443
+
444
+ // ── Call connected — play greeting ──────────────────────────────────
445
+ case 'call.answered': {
446
+ // Fallback: if call.initiated raced and session isn't created yet, init now.
447
+ if (!this._hasSession(ccId)) {
448
+ const caller = payload.from || payload.to || ccId;
449
+ this._initSession(ccId, caller);
450
+ console.log(`[TelnyxVoice] call.answered race — session created late for ${ccId.slice(-8)}`);
451
+ }
452
+ const sess = this._session(ccId);
453
+ // Non-whitelisted caller in secret-code mode — stay silent and start timer
454
+ if (sess.awaitingSecret) {
455
+ this._startSecretTimer(ccId);
456
+ break;
457
+ }
458
+ sess.isProcessing = true;
459
+ sess.awaitingUserInput = true;
460
+ const greetText = sess._outboundGreeting || 'Hello! I am your AI assistant. How can I help you?';
461
+ delete sess._outboundGreeting;
462
+ await this._sayText(ccId, greetText);
463
+ break;
464
+ }
465
+
466
+ // ── Playback lifecycle ──────────────────────────────────────────────
467
+ case 'call.playback.started':
468
+ // Only set isProcessing for audio we care about (not mid-think noise).
469
+ if (this._hasSession(ccId) && !this._session(ccId).isThinking)
470
+ this._session(ccId).isProcessing = true;
471
+ break;
472
+
473
+ case 'call.playback.ended':
474
+ case 'call.speak.ended': {
475
+ if (!this._hasSession(ccId)) break;
476
+ const sess = this._session(ccId);
477
+ // While the agent is thinking (think audio looping) or already thinking,
478
+ // ignore these events — they are from the think-loop audio, not the response.
479
+ if (sess.isThinking) break;
480
+ sess.isProcessing = false;
481
+ if (!sess.awaitingUserInput) break;
482
+ sess.awaitingUserInput = false;
483
+ setTimeout(async () => {
484
+ try {
485
+ await this._startRecording(ccId);
486
+ this._scheduleRecordingStop(ccId);
487
+ } catch {}
488
+ }, 200);
489
+ break;
490
+ }
491
+
492
+ // ── DTMF key — secret-code entry or interrupt-and-restart recording ──
493
+ case 'call.dtmf.received': {
494
+ if (!this._hasSession(ccId)) break;
495
+ const sess = this._session(ccId);
496
+
497
+ // ── Secret-code gating mode ─────────────────────────────────────────
498
+ if (sess.awaitingSecret) {
499
+ const digit = String(payload.digit ?? payload.dtmf_digit ?? '').trim();
500
+ if (/^[0-9]$/.test(digit)) {
501
+ sess.secretDigits += digit;
502
+ // Compare once we have enough digits
503
+ if (this.voiceSecret && sess.secretDigits.length >= this.voiceSecret.length) {
504
+ this._cancelSecretTimer(ccId);
505
+ if (sess.secretDigits === this.voiceSecret) {
506
+ // ── Correct code — transition to normal call flow ──────────
507
+ console.log(`[TelnyxVoice] Secret accepted for ${ccId.slice(-8)} (${sess.callerNumber})`);
508
+ sess.awaitingSecret = false;
509
+ sess.secretDigits = '';
510
+ sess.isProcessing = true;
511
+ sess.awaitingUserInput = true;
512
+ await this._sayText(ccId, 'Hello! I am your AI assistant. How can I help you?');
513
+ } else {
514
+ // ── Wrong code — ban and hang up ───────────────────────────
515
+ console.log(`[TelnyxVoice] Wrong secret from ${sess.callerNumber}, banning`);
516
+ this._banNumber(sess.callerNumber);
517
+ this._endSession(ccId);
518
+ try { await this._hangupCall(ccId); } catch {}
519
+ }
520
+ }
521
+ }
522
+ break;
523
+ }
524
+
525
+ // ── Normal in-call DTMF — interrupt and restart recording ──────────
526
+ this._cancelRecordingTimer(ccId);
527
+ sess.isProcessing = true;
528
+ sess.awaitingUserInput = false;
529
+ sess.isThinking = false; // cancel think state if user interrupts
530
+ sess.replySent = false; // allow a fresh reply for the new turn
531
+ await this._stopAudio(ccId);
532
+ await this._stopRecording(ccId);
533
+ setTimeout(async () => {
534
+ if (!this._hasSession(ccId)) return;
535
+ this._session(ccId).isProcessing = false;
536
+ try {
537
+ await this._startRecording(ccId);
538
+ this._scheduleRecordingStop(ccId);
539
+ } catch {}
540
+ }, 150);
541
+ break;
542
+ }
543
+
544
+ // ── Recording saved — STT → emit message → agent replies ───────────
545
+ case 'call.recording.saved': {
546
+ this._cancelRecordingTimer(ccId);
547
+ if (!this._hasSession(ccId)) break;
548
+ const sess = this._session(ccId);
549
+
550
+ const recordingUrl = payload.recording_urls?.mp3;
551
+ if (!recordingUrl) break;
552
+ // Dedup before isProcessing check — prevents Telnyx retries from slipping through.
553
+ if (sess.processedRecordings.has(recordingUrl)) break;
554
+ sess.processedRecordings.add(recordingUrl);
555
+
556
+ if (sess.isProcessing) break;
557
+
558
+ sess.isProcessing = true;
559
+ sess.awaitingUserInput = false;
560
+
561
+ // Download + transcribe
562
+ const recFile = this._tmpFile('rec', ccId);
563
+ const recPath = path.join(AUDIO_DIR, recFile);
564
+ try {
565
+ await this._downloadRecording(recordingUrl, recPath);
566
+ } catch (err) {
567
+ console.error('[TelnyxVoice] Failed to download recording:', err.message);
568
+ sess.isProcessing = false;
569
+ break;
570
+ }
571
+
572
+ const transcript = await this._stt(recPath);
573
+ fs.unlink(recPath, () => {});
574
+
575
+ if (!transcript?.trim()) {
576
+ // Nothing intelligible — restart recording
577
+ console.log(`[TelnyxVoice] Empty transcript for ${ccId}, restarting recording`);
578
+ sess.isProcessing = false;
579
+ sess.awaitingUserInput = true;
580
+ try { await this._startRecording(ccId); this._scheduleRecordingStop(ccId); } catch {}
581
+ break;
582
+ }
583
+
584
+ console.log(`[TelnyxVoice] Transcript [${sess.callerNumber}]: ${transcript}`);
585
+
586
+ // Mark as thinking — gates call.playback.ended so think-audio events
587
+ // don't corrupt session state while the agent is processing.
588
+ sess.isThinking = true;
589
+ sess.replySent = false;
590
+
591
+ // Fire hold phrase and agent processing in parallel — the pre-cached
592
+ // think audio plays instantly while the AI starts working immediately.
593
+ this._playThinkAudio(ccId).catch(err =>
594
+ console.error('[TelnyxVoice] Failed to play think audio:', err.message)
595
+ );
596
+
597
+ // Emit message event — MessagingManager routes it to the AI engine.
598
+ // The agent will call sendMessage(ccId, response) when it has a reply.
599
+ this.emit('message', {
600
+ messageId: `telnyx_${ccId}_${Date.now()}`,
601
+ chatId: ccId,
602
+ sender: sess.callerNumber || ccId,
603
+ senderName: sess.callerNumber || 'Caller',
604
+ content: transcript,
605
+ isGroup: false,
606
+ mediaType: 'voice',
607
+ timestamp: new Date().toISOString(),
608
+ });
609
+ break;
610
+ }
611
+
612
+ // ── Hangup — clean up session ───────────────────────────────────────
613
+ case 'call.hangup': {
614
+ this._endSession(ccId);
615
+ console.log(`[TelnyxVoice] Call ended (${ccId.slice(-8)})`);
616
+ break;
617
+ }
618
+
619
+ default:
620
+ break;
621
+ }
622
+ } catch (err) {
623
+ console.error(`[TelnyxVoice] Error handling ${eventType} for ${ccId}:`, err.message || err);
624
+ }
625
+ }
626
+
627
+ // ── sendMessage — agent TTS reply to an active call ────────────────────────
628
+ // `to` is the callControlId (= msg.chatId from the message event)
629
+
630
+ async sendMessage(to, content, _options = {}) {
631
+ const sess = this._session(to);
632
+ if (!sess) {
633
+ console.warn(`[TelnyxVoice] sendMessage: no active session for ${to} (call may have ended)`);
634
+ return { success: false, reason: 'call_ended' };
635
+ }
636
+
637
+ // Guard against the agent calling send_message more than once per turn.
638
+ if (sess.replySent) {
639
+ console.warn(`[TelnyxVoice] sendMessage: reply already sent for this turn, ignoring duplicate`);
640
+ return { success: false, reason: 'already_replied' };
641
+ }
642
+ sess.replySent = true;
643
+ // Keep isThinking=true until the response audio command is accepted by Telnyx.
644
+ // This blocks any stray call.playback.ended (from the think-audio stop) from
645
+ // corrupting session state during the transition window.
646
+
647
+ // Stop the "please hold" TTS (suppress all errors — it may have already ended)
648
+ try { await this._stopAudio(to); } catch {}
649
+
650
+ // Generate TTS response and play it.
651
+ // If anything here throws, reset replySent so the session isn't bricked.
652
+ try {
653
+ // Commit state before firing audio so call.playback/speak.ended
654
+ // belongs to this response, not any residual think audio.
655
+ sess.isThinking = false;
656
+ sess.isProcessing = true;
657
+ sess.awaitingUserInput = true;
658
+ await this._sayText(to, content);
659
+ } catch (err) {
660
+ // Audio failed — reset so the turn isn't silently lost.
661
+ sess.replySent = false;
662
+ sess.isThinking = false;
663
+ sess.isProcessing = false;
664
+ console.error('[TelnyxVoice] sendMessage failed:', err.message);
665
+ throw err;
666
+ }
667
+
668
+ return { success: true };
669
+ }
670
+
671
+ // ── Initiate outbound call (optional, for agent-triggered calls) ────────────
672
+
673
+ async initiateCall(to, greetingText) {
674
+ if (!this._client) throw new Error('Telnyx not connected');
675
+ if (!this._isAllowed(to)) throw new Error(`Number ${to} not in whitelist`);
676
+ const webhookUrl = `${this.webhookUrl}/api/telnyx/webhook${this._webhookToken ? `?token=${this._webhookToken}` : ''}`;
677
+ const call = await this._client.calls.dial({
678
+ to,
679
+ from: this.phoneNumber,
680
+ connection_id: this.connectionId,
681
+ webhook_url: webhookUrl,
682
+ });
683
+ const ccId = call.data.call_control_id;
684
+ this._initSession(ccId, to);
685
+ if (greetingText) {
686
+ // Store greeting — will be played on call.answered
687
+ this._session(ccId)._outboundGreeting = greetingText;
688
+ }
689
+ return { callControlId: ccId };
690
+ }
691
+ }
692
+
693
+ module.exports = { TelnyxVoicePlatform };