neoagent 2.1.1 → 2.1.2

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.
@@ -88,6 +88,32 @@ function systemImageArch() {
88
88
  return 'x86_64';
89
89
  }
90
90
 
91
+ function parseSystemImagePlatform(platformId) {
92
+ const stable = String(platformId || '').match(/^android-(\d+)$/);
93
+ if (stable) {
94
+ return {
95
+ platformId,
96
+ apiLevel: Number(stable[1] || 0),
97
+ stable: true,
98
+ };
99
+ }
100
+
101
+ const preview = String(platformId || '').match(/^android-([A-Za-z][A-Za-z0-9_-]*)$/);
102
+ if (preview) {
103
+ return {
104
+ platformId,
105
+ apiLevel: 0,
106
+ stable: false,
107
+ };
108
+ }
109
+
110
+ return {
111
+ platformId,
112
+ apiLevel: 0,
113
+ stable: false,
114
+ };
115
+ }
116
+
91
117
  function sdkEnv() {
92
118
  const base = {
93
119
  ...process.env,
@@ -205,21 +231,51 @@ function parseLatestCmdlineToolsUrl(xml) {
205
231
  throw new Error(`Could not find a command line tools archive for ${tag}`);
206
232
  }
207
233
 
234
+ function systemImageTagScore(tag) {
235
+ const value = String(tag || '').toLowerCase();
236
+ if (value.startsWith('google_apis_playstore')) return 50;
237
+ if (value.startsWith('google_apis')) return 40;
238
+ if (value === 'google_atd') return 30;
239
+ if (value === 'aosp_atd') return 20;
240
+ if (value === 'default') return 10;
241
+ return 0;
242
+ }
243
+
208
244
  function chooseLatestSystemImage(listOutput) {
209
245
  const arch = systemImageArch();
210
246
  const matches = [];
211
- const regex = new RegExp(`system-images;android-(\\d+);google_apis;${arch}`, 'g');
247
+ const regex = /system-images;(android-[^;\s]+);([^;\s]+);([^;\s]+)/g;
212
248
  let match = regex.exec(listOutput);
213
249
  while (match) {
250
+ if (match[3] !== arch) {
251
+ match = regex.exec(listOutput);
252
+ continue;
253
+ }
254
+
255
+ const platform = parseSystemImagePlatform(match[1]);
214
256
  matches.push({
215
- apiLevel: Number(match[1] || 0),
216
257
  packageName: match[0],
258
+ platformId: match[1],
259
+ tag: match[2],
260
+ arch: match[3],
261
+ apiLevel: platform.apiLevel,
262
+ stable: platform.stable,
263
+ tagScore: systemImageTagScore(match[2]),
217
264
  });
218
265
  match = regex.exec(listOutput);
219
266
  }
220
267
 
221
- matches.sort((a, b) => b.apiLevel - a.apiLevel);
222
- return matches[0] || null;
268
+ const preferredMatches = matches.filter((candidate) => candidate.tagScore > 0);
269
+ const pool = preferredMatches.length > 0 ? preferredMatches : matches;
270
+
271
+ pool.sort((a, b) =>
272
+ Number(b.stable) - Number(a.stable) ||
273
+ b.apiLevel - a.apiLevel ||
274
+ b.tagScore - a.tagScore ||
275
+ a.packageName.localeCompare(b.packageName)
276
+ );
277
+
278
+ return pool[0] || null;
223
279
  }
224
280
 
225
281
  function parseApiLevelFromSystemImage(packageName) {
@@ -349,7 +405,7 @@ class AndroidController {
349
405
  const available = await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} --list`, { timeout: 300000 });
350
406
  const latestSystemImage = chooseLatestSystemImage(available);
351
407
  if (!latestSystemImage) {
352
- throw new Error(`No stable Google APIs system image found for ${systemImageArch()}`);
408
+ throw new Error(`No compatible Android system image found for ${systemImageArch()}`);
353
409
  }
354
410
 
355
411
  const state = readState();
@@ -401,7 +457,7 @@ class AndroidController {
401
457
  const available = await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} --list`, { timeout: 300000 });
402
458
  const systemImage = chooseLatestSystemImage(available);
403
459
  if (!systemImage) {
404
- throw new Error(`No stable Google APIs system image found for ${systemImageArch()}`);
460
+ throw new Error(`No compatible Android system image found for ${systemImageArch()}`);
405
461
  }
406
462
 
407
463
  await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} "${systemImage.packageName}"`, { timeout: 300000 });
@@ -869,6 +925,9 @@ class AndroidController {
869
925
  emulatorPath: emulatorBinary(),
870
926
  serial: state.serial,
871
927
  emulatorPid: state.emulatorPid,
928
+ systemImage: state.systemImage || null,
929
+ apiLevel: Number(state.apiLevel || 0) || null,
930
+ avdSystemImage: state.avdSystemImage || null,
872
931
  logPath: state.logPath || null,
873
932
  lastLogLine,
874
933
  devices,
@@ -14,7 +14,7 @@ const { AGENT_DATA_DIR } = require('../../../runtime/paths');
14
14
  async function getActiveProvider(userId) {
15
15
  try {
16
16
  const { getSupportedModels } = require('../ai/models');
17
- const models = await getSupportedModels();
17
+ const models = await getSupportedModels(userId);
18
18
  const rows = db.prepare('SELECT key, value FROM user_settings WHERE user_id = ? AND key IN (?, ?)')
19
19
  .all(userId || 1, 'default_chat_model', 'enabled_models');
20
20
 
@@ -33,7 +33,7 @@ async function getActiveProvider(userId) {
33
33
  : (Array.isArray(enabledIds) && enabledIds.length > 0 ? enabledIds[0] : null);
34
34
 
35
35
  if (modelId) {
36
- const def = models.find(m => m.id === modelId);
36
+ const def = models.find(m => m.id === modelId && m.available !== false);
37
37
  if (def) return def.provider;
38
38
  }
39
39
  } catch { }
@@ -3,18 +3,6 @@
3
3
  const { BasePlatform } = require('./base');
4
4
  const TelegramBot = require('node-telegram-bot-api');
5
5
 
6
- /**
7
- * Whitelist entry format (prefixed strings):
8
- * "user:ID" → always respond in DMs, no mention needed
9
- * "group:ID" → respond in this group/supergroup when @mentioned
10
- * "ID" → legacy plain ID, treated as "user"
11
- *
12
- * Telegram group/supergroup IDs are negative numbers (e.g. -1001234567890).
13
- *
14
- * chatId emitted on message events:
15
- * DMs: "dm_<userId>"
16
- * Groups: "<chatId>" (negative integer as string)
17
- */
18
6
  class TelegramPlatform extends BasePlatform {
19
7
  constructor(config = {}) {
20
8
  super('telegram', config);
@@ -28,13 +16,10 @@ class TelegramPlatform extends BasePlatform {
28
16
 
29
17
  this._bot = null;
30
18
  this._botUser = null;
31
- // In-memory ring buffer of recent messages per raw chatId (Telegram has no fetch history API for bots)
32
19
  this._contextBuffers = new Map();
33
20
  this._contextMaxSize = 25;
34
21
  }
35
22
 
36
- // ── Lifecycle ──────────────────────────────────────────────────────────────
37
-
38
23
  async connect() {
39
24
  if (!this.botToken) throw new Error('Telegram bot token is required');
40
25
 
@@ -78,21 +63,15 @@ class TelegramPlatform extends BasePlatform {
78
63
  getStatus() { return this.status; }
79
64
  getAuthInfo() { return this._botUser ? { username: this._botUser.username, id: this._botUser.id } : null; }
80
65
 
81
- // ── Whitelist ──────────────────────────────────────────────────────────────
82
-
83
- // Inherits setAllowedEntries from BasePlatform
84
-
85
- /** Returns {allowed, requireMention} */
86
66
  _checkAccess(msg) {
87
67
  const userId = String(msg.from.id);
88
- const chatId = String(msg.chat.id); // negative for groups
68
+ const chatId = String(msg.chat.id);
89
69
  const isPrivate = msg.chat.type === 'private';
90
70
 
91
- // Default behavior with no allow-list: respond in private chats and require @mention in groups.
92
71
  if (this.allowedEntries.size === 0) return { allowed: true, requireMention: !isPrivate };
93
72
 
94
73
  if (super._checkAccess(`user:${userId}`)) return { allowed: true, requireMention: false };
95
- if (super._checkAccess(userId)) return { allowed: true, requireMention: false }; // legacy
74
+ if (super._checkAccess(userId)) return { allowed: true, requireMention: false };
96
75
  if (super._checkAccess(`group:${chatId}`)) return { allowed: true, requireMention: true };
97
76
 
98
77
  return { allowed: false, requireMention: false };
@@ -119,8 +98,6 @@ class TelegramPlatform extends BasePlatform {
119
98
  .trim();
120
99
  }
121
100
 
122
- // ── Context buffer (since Telegram bots can't fetch message history) ───────
123
-
124
101
  _addToContext(rawChatId, entry) {
125
102
  if (!this._contextBuffers.has(rawChatId)) this._contextBuffers.set(rawChatId, []);
126
103
  const buf = this._contextBuffers.get(rawChatId);
@@ -132,8 +109,6 @@ class TelegramPlatform extends BasePlatform {
132
109
  return [...(this._contextBuffers.get(rawChatId) || [])];
133
110
  }
134
111
 
135
- // ── Message handler ────────────────────────────────────────────────────────
136
-
137
112
  async _handleMessage(msg) {
138
113
  if (!msg.from || msg.from.is_bot) return;
139
114
 
@@ -146,7 +121,6 @@ class TelegramPlatform extends BasePlatform {
146
121
  const senderName = [msg.from.first_name, msg.from.last_name].filter(Boolean).join(' ')
147
122
  || msg.from.username || userId;
148
123
 
149
- // Always record into context buffer (even blocked messages add context)
150
124
  this._addToContext(rawChatId, {
151
125
  author: senderName,
152
126
  content: text || (msg.photo ? '[photo]' : msg.document ? '[document]' : '[empty]'),
@@ -174,7 +148,6 @@ class TelegramPlatform extends BasePlatform {
174
148
  return;
175
149
  }
176
150
 
177
- // Group entries require @mention to activate
178
151
  if (requireMention && !this._isMentioned(msg)) return;
179
152
 
180
153
  let content = requireMention ? this._stripMention(text) : text;
@@ -204,18 +177,12 @@ class TelegramPlatform extends BasePlatform {
204
177
  });
205
178
  }
206
179
 
207
- // ── Send ───────────────────────────────────────────────────────────────────
208
-
209
- /**
210
- * to: "dm_<userId>" for DMs, or a raw chat ID (e.g. "-1001234567890") for groups
211
- */
212
180
  async sendMessage(to, content, _options = {}) {
213
181
  if (!this._bot || this.status !== 'connected') throw new Error('Telegram not connected');
214
182
 
215
183
  const telegramChatId = to.startsWith('dm_') ? to.slice(3) : to;
216
184
  await this._bot.sendMessage(telegramChatId, content);
217
185
 
218
- // Store outgoing message in context buffer
219
186
  if (this._botUser) {
220
187
  this._addToContext(telegramChatId, {
221
188
  author: `[bot] ${this._botUser.username}`,
@@ -232,7 +199,7 @@ class TelegramPlatform extends BasePlatform {
232
199
  try {
233
200
  const id = chatId.startsWith('dm_') ? chatId.slice(3) : chatId;
234
201
  await this._bot.sendChatAction(id, 'typing');
235
- } catch { /* non-fatal */ }
202
+ } catch {}
236
203
  }
237
204
  }
238
205
 
@@ -8,41 +8,35 @@ const { OpenAI } = require('openai');
8
8
  const { DATA_DIR, AGENT_DATA_DIR } = require('../../../runtime/paths');
9
9
 
10
10
  const AUDIO_DIR = path.join(DATA_DIR, 'telnyx-audio');
11
- const RECORDING_TURN_LIMIT_MS = 4000; // auto-stop recording after 4 s of silence
11
+ const RECORDING_TURN_LIMIT_MS = 4000;
12
12
 
13
13
  class TelnyxVoicePlatform extends BasePlatform {
14
14
  constructor(config = {}) {
15
15
  super('telnyx', config);
16
16
  this.supportsVoice = true;
17
17
 
18
- // Config fields set via the web UI connect modal
19
- this.apiKey = config.apiKey || '';
18
+ this.apiKey = config.apiKey || '';
20
19
  this.phoneNumber = config.phoneNumber || '';
21
20
  this.connectionId = config.connectionId || '';
22
- this.webhookUrl = config.webhookUrl || ''; // e.g. https://xyz.ngrok.io
23
- this.ttsVoice = config.ttsVoice || 'alloy';
24
- this.ttsModel = config.ttsModel || 'tts-1';
25
- this.sttModel = config.sttModel || 'whisper-1';
26
-
27
- // Allowed-numbers whitelist (empty = allow all)
28
- this.allowedNumbers = Array.isArray(config.allowedNumbers) ? config.allowedNumbers : [];
29
-
30
- // Secret code for non-whitelisted inbound callers (digits only; empty = feature disabled)
21
+ this.webhookUrl = config.webhookUrl || '';
22
+ this.ttsVoice = config.ttsVoice || 'alloy';
23
+ this.ttsModel = config.ttsModel || 'tts-1';
24
+ this.sttModel = config.sttModel || 'whisper-1';
25
+ this.allowedNumbers = Array.isArray(config.allowedNumbers)
26
+ ? config.allowedNumbers
27
+ : [];
31
28
  this.voiceSecret = String(config.voiceSecret || '').replace(/\D/g, '');
32
29
 
33
- // Runtime state
34
- this._sessions = new Map(); // ccId → session object
35
- this._recordingTimers = new Map(); // ccId → setTimeout handle
36
- this._secretTimers = new Map(); // ccId → secret-entry timeout handle
37
- this._bannedNumbers = new Map(); // normalizedNumber → ban expiry timestamp
38
- this._client = null; // Telnyx SDK instance
39
- this._openai = null; // OpenAI client
40
- this._webhookToken = null; // resolved at connect time from TELNYX_WEBHOOK_TOKEN
41
- this._thinkAudioFile = null; // pre-cached "hold" audio filename
30
+ this._sessions = new Map();
31
+ this._recordingTimers = new Map();
32
+ this._secretTimers = new Map();
33
+ this._bannedNumbers = new Map();
34
+ this._client = null;
35
+ this._openai = null;
36
+ this._webhookToken = null;
37
+ this._thinkAudioFile = null;
42
38
  }
43
39
 
44
- // ── Lifecycle ──────────────────────────────────────────────────────────────
45
-
46
40
  async connect() {
47
41
  if (!this.apiKey || !this.phoneNumber || !this.connectionId || !this.webhookUrl) {
48
42
  throw new Error('Telnyx Voice requires apiKey, phoneNumber, connectionId, and webhookUrl');
@@ -54,14 +48,13 @@ class TelnyxVoicePlatform extends BasePlatform {
54
48
  const TelnyxClient = TelnyxSDK.default || TelnyxSDK;
55
49
  this._client = new TelnyxClient({ apiKey: this.apiKey });
56
50
 
57
- // Resolve OpenAI key: env var → stored API_KEYS.json → none (fallback to Telnyx speak)
58
51
  let openAiKey = process.env.OPENAI_API_KEY;
59
52
  if (!openAiKey) {
60
53
  try {
61
54
  const keysPath = path.join(AGENT_DATA_DIR, 'API_KEYS.json');
62
55
  const keys = JSON.parse(fs.readFileSync(keysPath, 'utf8'));
63
56
  openAiKey = keys.OPENAI_API_KEY || keys.openai_api_key || keys.openai || null;
64
- } catch { /* file missing or unreadable — fine */ }
57
+ } catch {}
65
58
  }
66
59
  if (openAiKey) {
67
60
  this._openai = new OpenAI({ apiKey: openAiKey });
@@ -70,13 +63,11 @@ class TelnyxVoicePlatform extends BasePlatform {
70
63
  console.warn('[TelnyxVoice] No OpenAI API key found — TTS will use Telnyx native speak (language auto-detected)');
71
64
  }
72
65
 
73
- // Derive the full inbound webhook URL (with token) so it can be logged / displayed
74
66
  const token = process.env.TELNYX_WEBHOOK_TOKEN;
75
67
  this._webhookToken = token || null;
76
68
  const inboundUrl = `${this.webhookUrl}/api/telnyx/webhook${token ? `?token=${token}` : ''}`;
77
69
  console.log(`[TelnyxVoice] Inbound webhook URL (configure this in the Telnyx portal): ${inboundUrl}`);
78
70
 
79
- // Pre-generate the "thinking" hold audio so it's instant during calls
80
71
  this._precacheThinkAudio();
81
72
 
82
73
  this.status = 'connected';
@@ -106,8 +97,6 @@ class TelnyxVoicePlatform extends BasePlatform {
106
97
  getStatus() { return this.status; }
107
98
  getAuthInfo() { return { phoneNumber: this.phoneNumber }; }
108
99
 
109
- // ── Whitelist management ────────────────────────────────────────────────────
110
-
111
100
  setAllowedNumbers(numbers) {
112
101
  this.allowedNumbers = Array.isArray(numbers) ? numbers : [];
113
102
  console.log(`[TelnyxVoice] Whitelist updated: ${this.allowedNumbers.length} number(s)`);
@@ -169,19 +158,16 @@ class TelnyxVoicePlatform extends BasePlatform {
169
158
  if (t) { clearTimeout(t); this._secretTimers.delete(ccId); }
170
159
  }
171
160
 
172
- // ── Session helpers ────────────────────────────────────────────────────────
173
-
174
161
  _initSession(ccId, callerNumber = '') {
175
162
  this._sessions.set(ccId, {
176
163
  callerNumber,
177
- isProcessing: false,
178
- awaitingUserInput: false,
179
- isThinking: false, // true while agent is processing — gates playback.ended mutations
180
- replySent: false, // prevents double-reply within one agent turn
164
+ isProcessing: false,
165
+ awaitingUserInput: false,
166
+ isThinking: false,
167
+ replySent: false,
181
168
  processedRecordings: new Set(),
182
- // Secret-code gating (non-whitelisted callers)
183
- awaitingSecret: false,
184
- secretDigits: '',
169
+ awaitingSecret: false,
170
+ secretDigits: '',
185
171
  });
186
172
  }
187
173
 
@@ -210,8 +196,6 @@ class TelnyxVoicePlatform extends BasePlatform {
210
196
  if (t) { clearTimeout(t); this._recordingTimers.delete(ccId); }
211
197
  }
212
198
 
213
- // ── Telnyx call-control wrappers ───────────────────────────────────────────
214
-
215
199
  _isTerminalError(err) {
216
200
  const errs = (err.error?.errors) || err.errors ||
217
201
  (err.raw?.errors) || (err.response?.data?.errors);
@@ -263,10 +247,8 @@ class TelnyxVoicePlatform extends BasePlatform {
263
247
  catch (err) { if (!this._isTerminalError(err)) throw err; }
264
248
  }
265
249
 
266
- // ── Pre-cached think audio ─────────────────────────────────────────────────
267
-
268
250
  async _precacheThinkAudio() {
269
- if (!this._openai) return; // will use Telnyx speak fallback at playback time
251
+ if (!this._openai) return;
270
252
  try {
271
253
  const file = `think_hold_${Date.now()}.mp3`;
272
254
  const filePath = path.join(AUDIO_DIR, file);
@@ -284,7 +266,6 @@ class TelnyxVoicePlatform extends BasePlatform {
284
266
  }
285
267
  }
286
268
 
287
- // Play the pre-cached hold phrase (instant) or fall back to Telnyx speak.
288
269
  async _playThinkAudio(ccId) {
289
270
  if (this._thinkAudioFile) {
290
271
  try {
@@ -294,7 +275,6 @@ class TelnyxVoicePlatform extends BasePlatform {
294
275
  console.warn(`[TelnyxVoice] Pre-cached think audio failed: ${err.message}`);
295
276
  }
296
277
  }
297
- // Fallback: Telnyx native speak (still fast — no file gen needed)
298
278
  try {
299
279
  await this._client.calls.actions.speak(ccId, {
300
280
  payload: 'One moment please.',
@@ -306,8 +286,6 @@ class TelnyxVoicePlatform extends BasePlatform {
306
286
  }
307
287
  }
308
288
 
309
- // ── OpenAI TTS / STT ───────────────────────────────────────────────────────
310
-
311
289
  async _tts(text, destPath) {
312
290
  const mp3 = await this._openai.audio.speech.create({
313
291
  model: this.ttsModel,
@@ -318,8 +296,6 @@ class TelnyxVoicePlatform extends BasePlatform {
318
296
  await fs.promises.writeFile(destPath, buf);
319
297
  }
320
298
 
321
- // Say text on a call — tries OpenAI TTS+hosted audio first, falls back to
322
- // Telnyx native speak (no external hosting or OpenAI key required).
323
299
  async _sayText(ccId, text) {
324
300
  if (this._openai) {
325
301
  try {
@@ -333,7 +309,6 @@ class TelnyxVoicePlatform extends BasePlatform {
333
309
  console.warn(`[TelnyxVoice] OpenAI TTS failed (${err.message}), falling back to Telnyx speak`);
334
310
  }
335
311
  }
336
- // Telnyx native speak fallback
337
312
  try {
338
313
  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);
339
314
  await this._client.calls.actions.speak(ccId, {
@@ -360,8 +335,6 @@ class TelnyxVoicePlatform extends BasePlatform {
360
335
  }
361
336
  }
362
337
 
363
- // ── File helpers ───────────────────────────────────────────────────────────
364
-
365
338
  async _downloadRecording(url, dest) {
366
339
  return new Promise((resolve, reject) => {
367
340
  const file = fs.createWriteStream(dest);
@@ -384,74 +357,58 @@ class TelnyxVoicePlatform extends BasePlatform {
384
357
  return `${prefix}_${ccId.replace(/[^a-zA-Z0-9]/g, '')}_${Date.now()}.mp3`;
385
358
  }
386
359
 
387
- // ── Main webhook handler ───────────────────────────────────────────────────
388
- // Called by MessagingManager.handleTelnyxWebhook() from /api/telnyx/webhook
389
-
390
360
  async handleWebhook(event) {
391
361
  if (!event?.data?.event_type) return;
392
362
  const { event_type: eventType, payload } = event.data;
393
363
  const ccId = payload?.call_control_id;
394
364
  if (!ccId) return;
395
365
 
396
- // Ignore events for sessions we don't know about (except the ones that start one)
397
366
  if (!this._hasSession(ccId) &&
398
367
  eventType !== 'call.initiated' &&
399
368
  eventType !== 'call.answered') {
400
369
  return;
401
370
  }
402
371
 
403
- // Outbound call.initiated is handled by initiateCall() already
404
372
  if (eventType === 'call.initiated' && payload.direction === 'outbound') return;
405
373
 
406
374
  console.log(`[TelnyxVoice] ${eventType} — ccId=${ccId.slice(-8)}`);
407
375
 
408
376
  try {
409
377
  switch (eventType) {
410
-
411
- // ── Inbound call received ───────────────────────────────────────────
412
378
  case 'call.initiated': {
413
379
  if (payload.direction !== 'incoming') break;
414
380
  const caller = payload.from;
415
381
  if (!this._isAllowed(caller)) {
416
- // Check ban list first — banned callers are rejected immediately
417
382
  if (this._isBanned(caller)) {
418
383
  console.log(`[TelnyxVoice] Rejecting banned caller: ${caller}`);
419
384
  await this._rejectCall(ccId);
420
385
  this.emit('blocked_caller', { caller, ccId });
421
386
  break;
422
387
  }
423
- // If no secret is configured, fall back to the old reject behaviour
424
388
  if (!this.voiceSecret) {
425
389
  console.log(`[TelnyxVoice] Blocked non-whitelisted caller (no secret set): ${caller}`);
426
390
  await this._rejectCall(ccId);
427
391
  this.emit('blocked_caller', { caller, ccId });
428
392
  break;
429
393
  }
430
- // Secret configured — answer and wait silently for code entry
431
394
  console.log(`[TelnyxVoice] Non-whitelisted caller ${caller} — awaiting secret code`);
432
395
  this._initSession(ccId, caller);
433
396
  this._session(ccId).awaitingSecret = true;
434
397
  await this._answerCall(ccId);
435
398
  break;
436
399
  }
437
- // Init session BEFORE answering so call.answered (which arrives as a
438
- // separate concurrent webhook) always finds a valid session.
439
400
  this._initSession(ccId, caller);
440
401
  await this._answerCall(ccId);
441
402
  console.log(`[TelnyxVoice] Answered inbound call from ${caller}`);
442
403
  break;
443
404
  }
444
-
445
- // ── Call connected — play greeting ──────────────────────────────────
446
405
  case 'call.answered': {
447
- // Fallback: if call.initiated raced and session isn't created yet, init now.
448
406
  if (!this._hasSession(ccId)) {
449
407
  const caller = payload.from || payload.to || ccId;
450
408
  this._initSession(ccId, caller);
451
409
  console.log(`[TelnyxVoice] call.answered race — session created late for ${ccId.slice(-8)}`);
452
410
  }
453
411
  const sess = this._session(ccId);
454
- // Non-whitelisted caller in secret-code mode — stay silent and start timer
455
412
  if (sess.awaitingSecret) {
456
413
  this._startSecretTimer(ccId);
457
414
  break;
@@ -463,10 +420,7 @@ class TelnyxVoicePlatform extends BasePlatform {
463
420
  await this._sayText(ccId, greetText);
464
421
  break;
465
422
  }
466
-
467
- // ── Playback lifecycle ──────────────────────────────────────────────
468
423
  case 'call.playback.started':
469
- // Only set isProcessing for audio we care about (not mid-think noise).
470
424
  if (this._hasSession(ccId) && !this._session(ccId).isThinking)
471
425
  this._session(ccId).isProcessing = true;
472
426
  break;
@@ -475,8 +429,6 @@ class TelnyxVoicePlatform extends BasePlatform {
475
429
  case 'call.speak.ended': {
476
430
  if (!this._hasSession(ccId)) break;
477
431
  const sess = this._session(ccId);
478
- // While the agent is thinking (think audio looping) or already thinking,
479
- // ignore these events — they are from the think-loop audio, not the response.
480
432
  if (sess.isThinking) break;
481
433
  sess.isProcessing = false;
482
434
  if (!sess.awaitingUserInput) break;
@@ -489,30 +441,23 @@ class TelnyxVoicePlatform extends BasePlatform {
489
441
  }, 200);
490
442
  break;
491
443
  }
492
-
493
- // ── DTMF key — secret-code entry or interrupt-and-restart recording ──
494
444
  case 'call.dtmf.received': {
495
445
  if (!this._hasSession(ccId)) break;
496
446
  const sess = this._session(ccId);
497
-
498
- // ── Secret-code gating mode ─────────────────────────────────────────
499
447
  if (sess.awaitingSecret) {
500
448
  const digit = String(payload.digit ?? payload.dtmf_digit ?? '').trim();
501
449
  if (/^[0-9]$/.test(digit)) {
502
450
  sess.secretDigits += digit;
503
- // Compare once we have enough digits
504
451
  if (this.voiceSecret && sess.secretDigits.length >= this.voiceSecret.length) {
505
452
  this._cancelSecretTimer(ccId);
506
453
  if (sess.secretDigits === this.voiceSecret) {
507
- // ── Correct code — transition to normal call flow ──────────
508
454
  console.log(`[TelnyxVoice] Secret accepted for ${ccId.slice(-8)} (${sess.callerNumber})`);
509
- sess.awaitingSecret = false;
510
- sess.secretDigits = '';
511
- sess.isProcessing = true;
455
+ sess.awaitingSecret = false;
456
+ sess.secretDigits = '';
457
+ sess.isProcessing = true;
512
458
  sess.awaitingUserInput = true;
513
459
  await this._sayText(ccId, 'Hello! I am your AI assistant. How can I help you?');
514
460
  } else {
515
- // ── Wrong code — ban and hang up ───────────────────────────
516
461
  console.log(`[TelnyxVoice] Wrong secret from ${sess.callerNumber}, banning`);
517
462
  this._banNumber(sess.callerNumber);
518
463
  this._endSession(ccId);