From c5c903d4b4fb07d4967a57b9db8d93097c0d23ad Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 27 Sep 2026 15:06:41 +0330 Subject: [PATCH] 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 --- README.md | 2 +- .../migration.sql | 12 + apps/api/prisma/schema.prisma | 13 + apps/api/src/ai/ai.service.ts | 38 +++ .../integrations/voip/call-summary.service.ts | 241 ++++++++++++++++++ .../src/integrations/voip/calls.service.ts | 31 +++ .../integrations/voip/stt-client.service.ts | 122 +++++++++ apps/api/src/integrations/voip/voip.module.ts | 7 +- apps/web/src/views/VoipSettingsView.vue | 86 ++++++- docs/راهنما-و-نقشه-راه.md | 15 +- docs/قابلیت‌های-منتکت.md | 25 +- docs/وضعیت-پیشرفت.md | 27 +- packages/shared/src/index.ts | 18 ++ 13 files changed, 615 insertions(+), 22 deletions(-) create mode 100644 apps/api/prisma/migrations/20260927180000_voip_call_summary/migration.sql create mode 100644 apps/api/src/integrations/voip/call-summary.service.ts create mode 100644 apps/api/src/integrations/voip/stt-client.service.ts diff --git a/README.md b/README.md index d517004..4e077e4 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ $env:MENTO_DEV_API="http://127.0.0.1:3000"; npm run dev ## فاز فعلی -فاز ۱ هسته فروش/پشتیبانی و Smart Notes عملاً کامل است. جزئیات تیک‌خورده و باقی‌مانده: +فاز ۱ هسته فروش/پشتیبانی، Smart Notes، مالی سبک و اتصال‌ها (API، ووکامرس، VoIP، پیامک گروهی) عملاً کامل است. جزئیات تیک‌خورده و باقی‌مانده: - [`docs/وضعیت-پیشرفت.md`](docs/وضعیت-پیشرفت.md) ← **چک‌لیست زنده** - [`docs/قابلیت‌های-منتکت.md`](docs/قابلیت‌های-منتکت.md) ← فهرست کوتاه قابلیت‌ها diff --git a/apps/api/prisma/migrations/20260927180000_voip_call_summary/migration.sql b/apps/api/prisma/migrations/20260927180000_voip_call_summary/migration.sql new file mode 100644 index 0000000..7e4f1bf --- /dev/null +++ b/apps/api/prisma/migrations/20260927180000_voip_call_summary/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "VoipCallLog" ADD COLUMN "recordingUrl" TEXT; +ALTER TABLE "VoipCallLog" ADD COLUMN "transcript" TEXT; +ALTER TABLE "VoipCallLog" ADD COLUMN "summary" TEXT; +ALTER TABLE "VoipCallLog" ADD COLUMN "summaryStatus" TEXT NOT NULL DEFAULT 'none'; +ALTER TABLE "VoipCallLog" ADD COLUMN "summaryNoteId" TEXT; +ALTER TABLE "VoipCallLog" ADD COLUMN "summaryError" TEXT; +ALTER TABLE "VoipCallLog" ADD COLUMN "summaryAttempts" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "VoipCallLog" ADD COLUMN "summaryNextAttemptAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "VoipCallLog_summaryStatus_summaryNextAttemptAt_idx" ON "VoipCallLog"("summaryStatus", "summaryNextAttemptAt"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1be3989..834ae89 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -1855,6 +1855,18 @@ model VoipCallLog { startedAt DateTime @default(now()) endedAt DateTime? rawPayload Json? + /// لینک فایل ضبط از سانترال + recordingUrl String? + /// متن خام (از وب‌هوک یا STT) + transcript String? + /// خلاصه فارسی تولیدشده + summary String? + /// none | pending | processing | done | failed | skipped + summaryStatus String @default("none") + summaryNoteId String? + summaryError String? + summaryAttempts Int @default(0) + summaryNextAttemptAt DateTime? syncedAt DateTime @default(now()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1863,6 +1875,7 @@ model VoipCallLog { @@index([organizationId, syncedAt]) @@index([personId]) @@index([activityId]) + @@index([summaryStatus, summaryNextAttemptAt]) } /// خدمت قابل رزرو (مثلاً مشاوره ۳۰ دقیقه‌ای) diff --git a/apps/api/src/ai/ai.service.ts b/apps/api/src/ai/ai.service.ts index a340427..4722061 100644 --- a/apps/api/src/ai/ai.service.ts +++ b/apps/api/src/ai/ai.service.ts @@ -61,6 +61,44 @@ Rules: return this.normalizeExtract(raw); } + async summarizeCallTranscript(input: { + transcript: string; + personName?: string | null; + direction?: string | null; + }): Promise<{ summary: string; nextAction: string | null }> { + const system = `You summarize phone call transcripts for a Persian CRM. +Return ONLY valid JSON: +{ + "summary": string, + "nextAction": string | null +} +Rules: +- summary: 3–8 concise Persian sentences covering purpose, customer needs, outcomes, and commitments. +- nextAction: one short Persian follow-up if needed; otherwise null. +- Do not invent facts not in the transcript. +- Keep names, numbers, and amounts accurate.`; + + const userParts = [ + input.personName ? `Customer: ${input.personName}` : null, + input.direction ? `Direction: ${input.direction}` : null, + `Transcript:\n${input.transcript.trim().slice(0, 12000)}`, + ].filter(Boolean); + + const raw = await this.completeJson<{ + summary?: string; + nextAction?: string | null; + }>(system, userParts.join('\n')); + + const summary = String(raw.summary ?? '').trim().slice(0, 4000); + if (!summary) { + throw new BadRequestException('خلاصه تماس خالی بود'); + } + const nextAction = raw.nextAction + ? String(raw.nextAction).trim().slice(0, 500) || null + : null; + return { summary, nextAction }; + } + async completeJson(system: string, user: string): Promise { if (!this.isEnabled()) { throw new ServiceUnavailableException('هوش مصنوعی فعال نیست'); diff --git a/apps/api/src/integrations/voip/call-summary.service.ts b/apps/api/src/integrations/voip/call-summary.service.ts new file mode 100644 index 0000000..a56b83a --- /dev/null +++ b/apps/api/src/integrations/voip/call-summary.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/api/src/integrations/voip/calls.service.ts b/apps/api/src/integrations/voip/calls.service.ts index f07eeb4..869affc 100644 --- a/apps/api/src/integrations/voip/calls.service.ts +++ b/apps/api/src/integrations/voip/calls.service.ts @@ -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, }, }); } diff --git a/apps/api/src/integrations/voip/stt-client.service.ts b/apps/api/src/integrations/voip/stt-client.service.ts new file mode 100644 index 0000000..092bc05 --- /dev/null +++ b/apps/api/src/integrations/voip/stt-client.service.ts @@ -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('STT_ENABLED') === 'true'; + } + + async downloadAndTranscribe(recordingUrl: string): Promise { + if (!this.isEnabled()) { + throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست'); + } + + const baseUrl = this.config.get('STT_URL')?.trim(); + if (!baseUrl) { + throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده'); + } + + const timeoutMs = Number( + this.config.get('STT_TIMEOUT_MS') || 180000, + ); + const token = this.config.get('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 = {}; + 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); + } + } +} diff --git a/apps/api/src/integrations/voip/voip.module.ts b/apps/api/src/integrations/voip/voip.module.ts index 7fab511..8bda5d1 100644 --- a/apps/api/src/integrations/voip/voip.module.ts +++ b/apps/api/src/integrations/voip/voip.module.ts @@ -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], }) diff --git a/apps/web/src/views/VoipSettingsView.vue b/apps/web/src/views/VoipSettingsView.vue index 09526d5..428193d 100644 --- a/apps/web/src/views/VoipSettingsView.vue +++ b/apps/web/src/views/VoipSettingsView.vue @@ -1,5 +1,6 @@