2026-09-27 15:06:41 +03:30
|
|
|
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<string>('STT_ENABLED') === 'true';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async downloadAndTranscribe(recordingUrl: string): Promise<string> {
|
|
|
|
|
if (!this.isEnabled()) {
|
|
|
|
|
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-07 12:31:59 +03:30
|
|
|
const timeoutMs = Number(
|
|
|
|
|
this.config.get<string>('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<string> {
|
|
|
|
|
if (!this.isEnabled()) {
|
|
|
|
|
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-27 15:06:41 +03:30
|
|
|
const baseUrl = this.config.get<string>('STT_URL')?.trim();
|
|
|
|
|
if (!baseUrl) {
|
|
|
|
|
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-07 12:31:59 +03:30
|
|
|
const waitMs = timeoutMs ?? Number(
|
2026-09-27 15:06:41 +03:30
|
|
|
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
|
|
|
|
|
);
|
|
|
|
|
const token = this.config.get<string>('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<string, string> = {};
|
|
|
|
|
if (token) headers['X-STT-Token'] = token;
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController();
|
2026-11-07 12:31:59 +03:30
|
|
|
const timer = setTimeout(() => controller.abort(), waitMs);
|
2026-09-27 15:06:41 +03:30
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|