Files
CRM_Montzar/apps/api/src/integrations/voip/call-summary.service.ts
T
MahdiandCursor abb1a3a18e feat(voip): add Daftaresoma provider tab with webhook and click-to-call
Map portal call events into CRM logs, store the API token for OutgoingCall, and surface a dedicated settings tab for wiring the webhook URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 12:31:59 +03:30

271 lines
7.9 KiB
TypeScript

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 { VoipConnectionsService } from './connections.service';
import { DaftaresomaClient } from './daftareshoma-client';
import { parseDaftaresomaRecordingRef } from './daftareshoma-webhook';
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,
private readonly connections: VoipConnectionsService,
private readonly daftareshoma: DaftaresomaClient,
) {}
@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;
}
const daftId = parseDaftaresomaRecordingRef(row.recordingUrl);
if (daftId) {
const conn = await this.prisma.voipConnection.findFirst({
where: { id: row.connectionId },
});
const token = conn
? await this.connections.decryptApiToken(conn)
: null;
if (!token) {
await this.markFailed(
id,
row.summaryAttempts,
'توکن API دفتر شما برای دانلود ضبط موجود نیست',
true,
);
return;
}
const audio = await this.daftareshoma.downloadRecording(
token,
daftId,
);
transcript = await this.stt.transcribeAudio(audio);
} else {
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;
}
}