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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user