2026-09-27 15:06:41 +03:30
|
|
|
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';
|
2026-11-07 12:31:59 +03:30
|
|
|
import { VoipConnectionsService } from './connections.service';
|
|
|
|
|
import { DaftaresomaClient } from './daftareshoma-client';
|
|
|
|
|
import { parseDaftaresomaRecordingRef } from './daftareshoma-webhook';
|
2026-09-27 15:06:41 +03:30
|
|
|
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,
|
2026-11-07 12:31:59 +03:30
|
|
|
private readonly connections: VoipConnectionsService,
|
|
|
|
|
private readonly daftareshoma: DaftaresomaClient,
|
2026-09-27 15:06:41 +03:30
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
@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;
|
|
|
|
|
}
|
2026-11-07 12:31:59 +03:30
|
|
|
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);
|
|
|
|
|
}
|
2026-09-27 15:06:41 +03:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|