Add automatic VoIP call summaries and sync progress checklist.
When a PBX webhook includes recordingUrl or transcript, queue STT and AI to create a call_result note; mark API, bulk SMS, WooCommerce, and VoIP as done and defer online payments. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { AiService } from '../../ai/ai.service';
|
||||
import { NotesService } from '../../notes/notes.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { SttClientService } from './stt-client.service';
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
@Injectable()
|
||||
export class CallSummaryService {
|
||||
private readonly logger = new Logger(CallSummaryService.name);
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly stt: SttClientService,
|
||||
private readonly ai: AiService,
|
||||
private readonly notes: NotesService,
|
||||
) {}
|
||||
|
||||
@Cron('*/1 * * * *')
|
||||
async processDue(): Promise<number> {
|
||||
if (this.running) return 0;
|
||||
this.running = true;
|
||||
try {
|
||||
const now = new Date();
|
||||
const rows = await this.prisma.voipCallLog.findMany({
|
||||
where: {
|
||||
summaryStatus: 'pending',
|
||||
OR: [
|
||||
{ summaryNextAttemptAt: null },
|
||||
{ summaryNextAttemptAt: { lte: now } },
|
||||
],
|
||||
},
|
||||
orderBy: { syncedAt: 'asc' },
|
||||
take: 5,
|
||||
});
|
||||
|
||||
let processed = 0;
|
||||
for (const row of rows) {
|
||||
await this.processOne(row.id);
|
||||
processed += 1;
|
||||
}
|
||||
return processed;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async processOne(id: string): Promise<void> {
|
||||
const claimed = await this.prisma.voipCallLog.updateMany({
|
||||
where: { id, summaryStatus: 'pending' },
|
||||
data: {
|
||||
summaryStatus: 'processing',
|
||||
summaryAttempts: { increment: 1 },
|
||||
summaryError: null,
|
||||
},
|
||||
});
|
||||
if (!claimed.count) return;
|
||||
|
||||
const row = await this.prisma.voipCallLog.findUnique({ where: { id } });
|
||||
if (!row) return;
|
||||
|
||||
try {
|
||||
let transcript = (row.transcript ?? '').trim();
|
||||
if (!transcript && row.recordingUrl) {
|
||||
if (!this.stt.isEnabled()) {
|
||||
await this.markFailed(
|
||||
id,
|
||||
row.summaryAttempts,
|
||||
'سرویس تبدیل گفتار فعال نیست',
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
transcript = await this.stt.downloadAndTranscribe(row.recordingUrl);
|
||||
await this.prisma.voipCallLog.update({
|
||||
where: { id },
|
||||
data: { transcript },
|
||||
});
|
||||
}
|
||||
|
||||
if (!transcript) {
|
||||
await this.prisma.voipCallLog.update({
|
||||
where: { id },
|
||||
data: {
|
||||
summaryStatus: 'skipped',
|
||||
summaryError: 'ضبط یا متن تماس ارسال نشده',
|
||||
summaryNextAttemptAt: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.ai.isEnabled()) {
|
||||
await this.markFailed(
|
||||
id,
|
||||
row.summaryAttempts,
|
||||
'هوش مصنوعی فعال نیست',
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let personName: string | null = null;
|
||||
if (row.personId) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: row.personId },
|
||||
select: { firstName: true, lastName: true },
|
||||
});
|
||||
if (person) {
|
||||
personName = `${person.firstName} ${person.lastName}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const { summary, nextAction } = await this.ai.summarizeCallTranscript({
|
||||
transcript,
|
||||
personName,
|
||||
direction: row.direction,
|
||||
});
|
||||
|
||||
const authorId = await this.resolveAuthorId(row.organizationId, row.personId);
|
||||
if (!authorId) {
|
||||
await this.markFailed(
|
||||
id,
|
||||
row.summaryAttempts,
|
||||
'کاربر مالک/مدیر برای ثبت یادداشت پیدا نشد',
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyParts = [
|
||||
summary,
|
||||
nextAction ? `\nاقدام بعدی پیشنهادی: ${nextAction}` : null,
|
||||
'\n---\nمتن تماس:\n',
|
||||
transcript.slice(0, 8000),
|
||||
].filter(Boolean);
|
||||
|
||||
const note = await this.notes.create(row.organizationId, authorId, {
|
||||
title: personName
|
||||
? `خلاصه تماس — ${personName}`
|
||||
: 'خلاصه تماس تلفنی',
|
||||
body: bodyParts.join(''),
|
||||
noteType: 'call_result',
|
||||
tags: ['voip', 'auto-summary'],
|
||||
personId: row.personId,
|
||||
activityId: row.activityId,
|
||||
analyzeEnabled: true,
|
||||
visibility: 'team',
|
||||
});
|
||||
|
||||
if (row.activityId) {
|
||||
const activity = await this.prisma.activity.findFirst({
|
||||
where: { id: row.activityId },
|
||||
select: { description: true },
|
||||
});
|
||||
if (activity) {
|
||||
const base = (activity.description ?? '').trim();
|
||||
const block = `خلاصه خودکار:\n${summary}`;
|
||||
const nextDesc = base.includes('خلاصه خودکار:')
|
||||
? base.replace(/خلاصه خودکار:[\s\S]*$/m, block)
|
||||
: [base, block].filter(Boolean).join('\n\n');
|
||||
await this.prisma.activity.update({
|
||||
where: { id: row.activityId },
|
||||
data: { description: nextDesc.slice(0, 8000) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.voipCallLog.update({
|
||||
where: { id },
|
||||
data: {
|
||||
summary,
|
||||
summaryStatus: 'done',
|
||||
summaryNoteId: note.id,
|
||||
summaryError: null,
|
||||
summaryNextAttemptAt: null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message.slice(0, 400) : 'خطای ناشناخته';
|
||||
this.logger.warn(`call summary ${id}: ${message}`);
|
||||
await this.markFailed(id, row.summaryAttempts, message, false);
|
||||
}
|
||||
}
|
||||
|
||||
private async markFailed(
|
||||
id: string,
|
||||
attemptsSoFar: number,
|
||||
error: string,
|
||||
permanent: boolean,
|
||||
): Promise<void> {
|
||||
const attempts = attemptsSoFar;
|
||||
if (permanent || attempts >= MAX_ATTEMPTS) {
|
||||
await this.prisma.voipCallLog.update({
|
||||
where: { id },
|
||||
data: {
|
||||
summaryStatus: 'failed',
|
||||
summaryError: error,
|
||||
summaryNextAttemptAt: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const delayMin = Math.min(15, 2 ** Math.max(0, attempts - 1));
|
||||
await this.prisma.voipCallLog.update({
|
||||
where: { id },
|
||||
data: {
|
||||
summaryStatus: 'pending',
|
||||
summaryError: error,
|
||||
summaryNextAttemptAt: new Date(Date.now() + delayMin * 60_000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveAuthorId(
|
||||
organizationId: string,
|
||||
personId: string | null,
|
||||
): Promise<string | null> {
|
||||
if (personId) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
if (person?.ownerId) return person.ownerId;
|
||||
}
|
||||
const actor = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
isActive: true,
|
||||
role: { in: ['owner', 'admin'] },
|
||||
},
|
||||
select: { userId: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return actor?.userId ?? null;
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,12 @@ export class VoipCallsService {
|
||||
durationSec: r.durationSec,
|
||||
startedAt: r.startedAt.toISOString(),
|
||||
syncedAt: r.syncedAt.toISOString(),
|
||||
recordingUrl: r.recordingUrl,
|
||||
transcript: r.transcript,
|
||||
summary: r.summary,
|
||||
summaryStatus: r.summaryStatus,
|
||||
summaryNoteId: r.summaryNoteId,
|
||||
summaryError: r.summaryError,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -133,6 +139,8 @@ export class VoipCallsService {
|
||||
event.durationSec != null
|
||||
? Math.max(0, Math.round(Number(event.durationSec)))
|
||||
: null;
|
||||
const recordingUrl = String(event.recordingUrl ?? '').trim() || null;
|
||||
const transcript = String(event.transcript ?? '').trim() || null;
|
||||
|
||||
const existing = await this.prisma.voipCallLog.findUnique({
|
||||
where: {
|
||||
@@ -158,6 +166,24 @@ export class VoipCallsService {
|
||||
activityId = createdId || null;
|
||||
}
|
||||
|
||||
const nextRecordingUrl = recordingUrl ?? existing?.recordingUrl ?? null;
|
||||
const nextTranscript = transcript ?? existing?.transcript ?? null;
|
||||
const canQueueSummary =
|
||||
['completed', 'answered'].includes(status) &&
|
||||
Boolean(nextRecordingUrl || nextTranscript) &&
|
||||
!['done', 'pending', 'processing'].includes(
|
||||
existing?.summaryStatus ?? 'none',
|
||||
);
|
||||
|
||||
const summaryPatch: Prisma.VoipCallLogUpdateInput = {};
|
||||
if (recordingUrl) summaryPatch.recordingUrl = recordingUrl;
|
||||
if (transcript) summaryPatch.transcript = transcript;
|
||||
if (canQueueSummary) {
|
||||
summaryPatch.summaryStatus = 'pending';
|
||||
summaryPatch.summaryError = null;
|
||||
summaryPatch.summaryNextAttemptAt = new Date();
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await this.prisma.voipCallLog.update({
|
||||
where: { id: existing.id },
|
||||
@@ -173,6 +199,7 @@ export class VoipCallsService {
|
||||
endedAt,
|
||||
rawPayload: event as unknown as Prisma.InputJsonValue,
|
||||
syncedAt: new Date(),
|
||||
...summaryPatch,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -191,6 +218,10 @@ export class VoipCallsService {
|
||||
startedAt,
|
||||
endedAt,
|
||||
rawPayload: event as unknown as Prisma.InputJsonValue,
|
||||
recordingUrl,
|
||||
transcript,
|
||||
summaryStatus: canQueueSummary ? 'pending' : 'none',
|
||||
summaryNextAttemptAt: canQueueSummary ? new Date() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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('سرویس تبدیل گفتار فعال نیست');
|
||||
}
|
||||
|
||||
const baseUrl = this.config.get<string>('STT_URL')?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
|
||||
}
|
||||
|
||||
const timeoutMs = Number(
|
||||
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
|
||||
);
|
||||
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || '';
|
||||
|
||||
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
|
||||
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();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { BillingModule } from '../../billing/billing.module';
|
||||
import { NotesModule } from '../../notes/notes.module';
|
||||
import { NotificationsModule } from '../../notifications/notifications.module';
|
||||
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
|
||||
import { CallSummaryService } from './call-summary.service';
|
||||
import { VoipConnectionsService } from './connections.service';
|
||||
import { VoipCallsService } from './calls.service';
|
||||
import { SttClientService } from './stt-client.service';
|
||||
import { VoipController, VoipWebhookController } from './voip.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule, NotificationsModule],
|
||||
imports: [AuthModule, BillingModule, NotificationsModule, NotesModule],
|
||||
controllers: [VoipController, VoipWebhookController],
|
||||
providers: [
|
||||
TahesabCryptoService,
|
||||
VoipConnectionsService,
|
||||
VoipCallsService,
|
||||
SttClientService,
|
||||
CallSummaryService,
|
||||
],
|
||||
exports: [VoipConnectionsService, VoipCallsService],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user