import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; const MAX_AUDIO_BYTES = 8 * 1024 * 1024; @Injectable() export class SttClientService { private readonly logger = new Logger(SttClientService.name); constructor(private readonly config: ConfigService) {} isEnabled(): boolean { return this.config.get('STT_ENABLED') === 'true'; } async downloadAndTranscribe(recordingUrl: string): Promise { if (!this.isEnabled()) { throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست'); } const timeoutMs = Number( this.config.get('STT_TIMEOUT_MS') || 180000, ); const audio = await this.downloadAudio(recordingUrl, timeoutMs); return this.transcribeAudio(audio, timeoutMs); } async transcribeAudio( audio: { buffer: ArrayBuffer; contentType: string; filename: string }, timeoutMs?: number, ): Promise { if (!this.isEnabled()) { throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست'); } const baseUrl = this.config.get('STT_URL')?.trim(); if (!baseUrl) { throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده'); } const waitMs = timeoutMs ?? Number( this.config.get('STT_TIMEOUT_MS') || 180000, ); const token = this.config.get('STT_INTERNAL_TOKEN')?.trim() || ''; const form = new FormData(); const blob = new Blob([new Uint8Array(audio.buffer)], { type: audio.contentType, }); form.append('audio', blob, audio.filename); const headers: Record = {}; if (token) headers['X-STT-Token'] = token; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), waitMs); try { const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, { method: 'POST', headers, body: form, signal: controller.signal, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new ServiceUnavailableException( `خطای STT (${res.status})${text ? `: ${text.slice(0, 200)}` : ''}`, ); } const data = (await res.json()) as { text?: string }; const text = String(data.text ?? '').trim(); if (!text) { throw new ServiceUnavailableException('متن خالی از STT برگشت'); } return text; } catch (err) { if (err instanceof ServiceUnavailableException) throw err; if (err instanceof Error && err.name === 'AbortError') { throw new ServiceUnavailableException('زمان تبدیل گفتار تمام شد'); } this.logger.warn( `STT failed: ${err instanceof Error ? err.message : String(err)}`, ); throw new ServiceUnavailableException('تبدیل گفتار ناموفق بود'); } finally { clearTimeout(timer); } } private async downloadAudio( url: string, timeoutMs: number, ): Promise<{ buffer: ArrayBuffer; contentType: string; filename: string }> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), Math.min(timeoutMs, 60000)); try { const res = await fetch(url, { method: 'GET', signal: controller.signal, headers: { 'User-Agent': 'MontactCRM-VoIP/1.0' }, redirect: 'follow', }); if (!res.ok) { throw new ServiceUnavailableException( `دانلود ضبط ناموفق بود (${res.status})`, ); } const contentLength = Number(res.headers.get('content-length') || 0); if (contentLength > MAX_AUDIO_BYTES) { throw new ServiceUnavailableException('حجم فایل ضبط بیش از حد مجاز است'); } const buffer = await res.arrayBuffer(); if (buffer.byteLength > MAX_AUDIO_BYTES) { throw new ServiceUnavailableException('حجم فایل ضبط بیش از حد مجاز است'); } if (!buffer.byteLength) { throw new ServiceUnavailableException('فایل ضبط خالی است'); } const contentType = res.headers.get('content-type')?.split(';')[0]?.trim() || 'audio/wav'; const pathPart = url.split('?')[0] ?? url; const baseName = pathPart.split('/').pop() || 'recording.wav'; const filename = /\.\w+$/.test(baseName) ? baseName : `${baseName}.wav`; return { buffer, contentType, filename }; } catch (err) { if (err instanceof ServiceUnavailableException) throw err; if (err instanceof Error && err.name === 'AbortError') { throw new ServiceUnavailableException('زمان دانلود ضبط تمام شد'); } throw new ServiceUnavailableException('دانلود فایل ضبط ناموفق بود'); } finally { clearTimeout(timer); } } }