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:
2026-09-27 15:06:41 +03:30
co-authored by Cursor
parent c06c92b333
commit c5c903d4b4
13 changed files with 615 additions and 22 deletions
@@ -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],
})