-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathVoiceMessageHandler.js
More file actions
388 lines (336 loc) Β· 13 KB
/
Copy pathVoiceMessageHandler.js
File metadata and controls
388 lines (336 loc) Β· 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
const axios = require('axios');
const FormData = require('form-data');
const fs = require('fs');
const NexaraTranscriptionAdapter = require('./adapters/NexaraTranscriptionAdapter');
const TestTranscriptionAdapter = require('./adapters/TestTranscriptionAdapter');
/**
* Voice Message Handler - Extracted from StreamTelegramBot
* Handles voice transcription and processing with Nexara API
*/
class VoiceMessageHandler {
constructor(bot, nexaraApiKey, activityIndicator, mainBot, configFilePath = './configs/bot1.json') {
this.bot = bot;
this.nexaraApiKey = nexaraApiKey;
this.activityIndicator = activityIndicator;
this.mainBot = mainBot; // Reference to main bot for delegation
this.configFilePath = configFilePath;
this.pendingCommands = new Map(); // messageId -> { transcribedText, userId, chatId }
}
/**
* Get transcription method from config
*/
getTranscriptionMethod() {
try {
const config = JSON.parse(fs.readFileSync(this.configFilePath, 'utf8'));
return config.voiceTranscriptionMethod || 'nexara';
} catch (error) {
console.warn('[VoiceHandler] Config read error, using default:', error.message);
return 'nexara';
}
}
/**
* Set transcription method in config
*/
setTranscriptionMethod(method) {
try {
const config = JSON.parse(fs.readFileSync(this.configFilePath, 'utf8'));
config.voiceTranscriptionMethod = method;
fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2));
console.log(`[VoiceHandler] Transcription method set to: ${method}`);
} catch (error) {
throw new Error(`Failed to update config: ${error.message}`);
}
}
/**
* Get voice transcription instant setting from bot config
*/
getVoiceTranscriptionInstant() {
try {
const config = JSON.parse(fs.readFileSync(this.configFilePath, 'utf8'));
return config.voiceTranscriptionInstant || false;
} catch (error) {
console.warn('[VoiceHandler] Bot config read error, using default:', error.message);
return false;
}
}
/**
* Set voice transcription instant setting in bot config
*/
setVoiceTranscriptionInstant(enabled) {
try {
const config = JSON.parse(fs.readFileSync(this.configFilePath, 'utf8'));
config.voiceTranscriptionInstant = enabled;
fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2));
console.log(`[VoiceHandler] Voice transcription instant set to: ${enabled}`);
} catch (error) {
throw new Error(`Failed to update bot config: ${error.message}`);
}
}
/**
* Create transcription adapter based on config
*/
createTranscriptionAdapter() {
// Check if we're in test mode
const isTestMode = !this.nexaraApiKey || process.env.NODE_ENV === 'test';
if (isTestMode) {
return new TestTranscriptionAdapter();
}
// Always use Nexara API for transcription
return new NexaraTranscriptionAdapter(this.nexaraApiKey);
}
/**
* Handle voice messages with adapter pattern
*/
async handleVoiceMessage(msg) {
const chatId = msg.chat.id;
const userId = msg.from.id;
// Start typing indicator for voice processing
await this.activityIndicator.start(chatId);
try {
const adapter = this.createTranscriptionAdapter();
let transcribedText;
if (adapter.getName() === 'Test Mode') {
transcribedText = await adapter.transcribe(msg.voice.file_id);
console.log('[Voice] Test mode - using simulated transcription');
} else {
// Nexara API requires audio buffer
const file = await this.bot.getFile(msg.voice.file_id);
const audioBuffer = await this.downloadTelegramFile(file.file_path);
transcribedText = await adapter.transcribe(audioBuffer);
console.log(`[Voice] Using ${adapter.getName()} transcription`);
}
// Stop typing indicator
await this.activityIndicator.stop(chatId);
// Check if instant mode is enabled
const isInstantMode = this.getVoiceTranscriptionInstant();
if (isInstantMode) {
// Instant mode: execute directly without confirmation
await this.mainBot.safeSendMessage(chatId,
'π€ *Voice Message Processed*\n\n' +
`π **Text:** "${transcribedText}"\n\n` +
`π§ **Method:** ${adapter.getName()}\n\n` +
'β‘ **Instant Mode:** Sending to Claude...'
);
// Execute immediately
await this.executeVoiceCommand(transcribedText, userId, chatId);
} else {
// Normal mode: show confirmation buttons
const keyboard = {
inline_keyboard: [
[
{ text: 'β
OK', callback_data: `voice_confirm:${chatId}_${Date.now()}` },
{ text: 'β Cancel', callback_data: `voice_cancel:${chatId}_${Date.now()}` }
]
]
};
const confirmMsg = await this.mainBot.safeSendMessage(chatId,
'π€ *Voice Message Received*\n\n' +
`π **Text:** "${transcribedText}"\n\n` +
`π§ **Method:** ${adapter.getName()}\n\n` +
'β Execute this command?',
{
reply_markup: keyboard
}
);
// Store pending command with new message ID (with safety check)
if (confirmMsg && confirmMsg.message_id) {
this.pendingCommands.set(confirmMsg.message_id, {
transcribedText,
userId,
chatId
});
} else {
console.error('[Voice] Warning: confirmMsg or message_id is undefined, cannot store pending command');
}
}
} catch (error) {
console.error('[Voice] Processing error:', error);
// Stop typing indicator on error
await this.activityIndicator.stop(chatId);
// Send error message to user
try {
await this.mainBot.safeSendMessage(chatId,
'β *Voice Message Error*\n\n' +
'Sorry, I couldn\'t process your voice message.\n\n' +
`Error: ${error.message}`
);
} catch (sendError) {
console.error('[Voice] Failed to send error message:', sendError);
}
}
}
/**
* Execute voice command - common logic for both instant and confirmed modes
*/
async executeVoiceCommand(transcribedText, userId, chatId, processUserMessageCallback = null, messageId = null) {
try {
// Check if concat mode is enabled
if (this.mainBot.getConcatModeStatus(userId)) {
const bufferSize = await this.mainBot.addToMessageBuffer(userId, {
type: 'voice',
content: transcribedText,
imagePath: null
});
// For instant mode, we don't have messageId to edit, so send a new message
if (processUserMessageCallback) {
await this.mainBot.safeEditMessage(chatId, messageId,
`π **Voice Added to Buffer**\n\nπ€ Transcription: "${transcribedText}"\n\nBuffer: ${bufferSize} message${bufferSize > 1 ? 's' : ''}`
);
} else {
await this.mainBot.safeSendMessage(chatId,
`π **Voice Added to Buffer**\n\nπ€ Transcription: "${transcribedText}"\n\nBuffer: ${bufferSize} message${bufferSize > 1 ? 's' : ''}`
);
}
} else {
// Normal processing - add voice transcription prefix like in CONCAT mode
const prefixedMessage = `Voice Message Transcribe: ${transcribedText}`;
// For instant mode, we need to find the processUserMessageCallback
if (!processUserMessageCallback && this.mainBot.processUserMessage) {
await this.mainBot.processUserMessage(prefixedMessage, userId, chatId);
} else if (processUserMessageCallback) {
await processUserMessageCallback(prefixedMessage, userId, chatId);
} else {
console.error('[Voice] No way to process user message found');
}
}
} catch (error) {
console.error('[Voice] Error executing voice command:', error);
// Send error message
try {
await this.mainBot.safeSendMessage(chatId,
'β *Voice Command Error*\n\n' +
`Failed to execute command: ${error.message}`
);
} catch (sendError) {
console.error('[Voice] Failed to send execution error message:', sendError);
}
}
}
/**
* Handle voice command callbacks
*/
async handleVoiceCallback(data, chatId, messageId, userId, processUserMessageCallback) {
const pendingCommand = this.pendingCommands.get(messageId);
if (!pendingCommand) {
try {
await this.mainBot.safeEditMessage(chatId, messageId,
'β *Voice command expired*\n\nPlease send a new voice message.'
);
} catch {
// Silently handle edit errors for expired commands
}
return;
}
const { transcribedText } = pendingCommand;
try {
if (data.startsWith('voice_confirm:')) {
// Execute the command
await this.mainBot.safeEditMessage(chatId, messageId,
'β
*Executing voice command*\n\n' +
`π Command: "${transcribedText}"\n\n` +
'β³ Sending to Claude...'
);
// Remove from pending
this.pendingCommands.delete(messageId);
// Execute the voice command
await this.executeVoiceCommand(transcribedText, userId, chatId, processUserMessageCallback, messageId);
} else if (data.startsWith('voice_cancel:')) {
await this.mainBot.safeEditMessage(chatId, messageId,
'β *Voice command cancelled*'
);
this.pendingCommands.delete(messageId);
}
} catch {
// Silently handle edit errors for callback operations
}
}
/**
* Download Telegram file with retry logic for SSL errors
*/
async downloadTelegramFile(filePath) {
const MAX_RETRIES = 5;
const BASE_DELAY = 1000; // 1 second
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const botToken = this.bot.token;
const url = `https://api.telegram.org/file/bot${botToken}/${filePath}`;
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000
});
return Buffer.from(response.data);
} catch (error) {
const isSSLError = error.message.includes('SSL routines') ||
error.message.includes('packet length too long') ||
error.message.includes('tls_get_more_records') ||
error.code === 'ECONNRESET' ||
error.code === 'ETIMEDOUT';
if (isSSLError && attempt < MAX_RETRIES) {
const delay = BASE_DELAY * Math.pow(2, attempt - 1); // Exponential backoff
console.log(`[VoiceHandler] SSL connection error on attempt ${attempt}/${MAX_RETRIES}, retrying in ${delay}ms...`);
console.log(`[VoiceHandler] Error details: ${error.message}`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
// If it's not an SSL error or we've exhausted retries, throw the error
throw new Error(`Failed to download file after ${attempt} attempts: ${error.message}`);
}
}
}
/**
* Transcribe audio with Nexara API
*/
async transcribeWithNexara(audioBuffer) {
if (!this.nexaraApiKey) {
throw new Error('Nexara API key not configured. Voice messages unavailable.');
}
try {
console.log('[Nexara] Transcribing audio...');
// Create FormData with the audio file
const formData = new FormData();
// Add the audio file as a buffer with proper filename and content type
formData.append('file', audioBuffer, {
filename: 'audio.ogg',
contentType: 'audio/ogg'
});
// Add other required fields
formData.append('model', 'whisper-1');
const response = await axios.post('https://api.nexara.ru/api/v1/audio/transcriptions', formData, {
headers: {
'Authorization': `Bearer ${this.nexaraApiKey}`,
...formData.getHeaders()
},
timeout: 30000
});
if (response.data && response.data.text) {
console.log(`[Nexara] Transcribed: "${response.data.text}"`);
return response.data.text;
} else {
throw new Error('Empty response from Nexara API');
}
} catch (error) {
if (error.response) {
throw new Error(`Nexara API error: ${error.response.status} - ${error.response.data?.message || 'Unknown error'}`);
} else if (error.request) {
throw new Error('No response from Nexara API. Check internet connection.');
} else {
throw new Error(`Request error: ${error.message}`);
}
}
}
/**
* Cleanup pending commands
*/
cleanup() {
this.pendingCommands.clear();
}
/**
* Get stats for debugging
*/
getStats() {
return {
pendingVoiceCommands: this.pendingCommands.size
};
}
}
module.exports = VoiceMessageHandler;