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.
- package/lib/install_helpers.js +92 -0
- package/lib/manager.js +22 -46
- package/package.json +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +54559 -54167
- package/server/routes/settings.js +38 -3
- package/server/services/ai/engine.js +11 -6
- package/server/services/ai/models.js +155 -25
- package/server/services/ai/providers/anthropic.js +2 -1
- package/server/services/ai/providers/grok.js +1 -1
- package/server/services/ai/providers/openai.js +2 -1
- package/server/services/ai/settings.js +131 -17
- package/server/services/android/controller.js +65 -6
- package/server/services/memory/manager.js +2 -2
- package/server/services/messaging/telegram.js +3 -36
- package/server/services/messaging/telnyx.js +28 -83
|
@@ -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 =
|
|
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.
|
|
222
|
-
|
|
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
|
|
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
|
|
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);
|
|
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 };
|
|
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 {
|
|
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;
|
|
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
|
-
|
|
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
|
|
23
|
-
this.ttsVoice
|
|
24
|
-
this.ttsModel
|
|
25
|
-
this.sttModel
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
this.
|
|
35
|
-
this.
|
|
36
|
-
this.
|
|
37
|
-
this.
|
|
38
|
-
this.
|
|
39
|
-
this.
|
|
40
|
-
this.
|
|
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 {
|
|
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:
|
|
178
|
-
awaitingUserInput:
|
|
179
|
-
isThinking:
|
|
180
|
-
replySent:
|
|
164
|
+
isProcessing: false,
|
|
165
|
+
awaitingUserInput: false,
|
|
166
|
+
isThinking: false,
|
|
167
|
+
replySent: false,
|
|
181
168
|
processedRecordings: new Set(),
|
|
182
|
-
|
|
183
|
-
|
|
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;
|
|
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
|
|
510
|
-
sess.secretDigits
|
|
511
|
-
sess.isProcessing
|
|
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);
|