From abb1a3a18e16f51706126f3d9cbcf899280075d7 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 7 Nov 2026 12:31:59 +0330 Subject: [PATCH] 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 --- .../migration.sql | 10 + apps/api/prisma/schema.prisma | 15 +- .../integrations/voip/call-summary.service.ts | 31 +- .../src/integrations/voip/calls.service.ts | 139 ++++++- .../integrations/voip/connections.service.ts | 72 +++- .../integrations/voip/daftareshoma-client.ts | 126 ++++++ .../integrations/voip/daftareshoma-webhook.ts | 156 +++++++ apps/api/src/integrations/voip/dto.ts | 35 ++ .../integrations/voip/stt-client.service.ts | 20 +- .../src/integrations/voip/voip.controller.ts | 18 +- apps/api/src/integrations/voip/voip.module.ts | 2 + apps/web/src/views/VoipSettingsView.vue | 384 ++++++++++++++---- packages/shared/src/index.ts | 16 + 13 files changed, 934 insertions(+), 90 deletions(-) create mode 100644 apps/api/prisma/migrations/20261107120000_voip_daftareshoma/migration.sql create mode 100644 apps/api/src/integrations/voip/daftareshoma-client.ts create mode 100644 apps/api/src/integrations/voip/daftareshoma-webhook.ts diff --git a/apps/api/prisma/migrations/20261107120000_voip_daftareshoma/migration.sql b/apps/api/prisma/migrations/20261107120000_voip_daftareshoma/migration.sql new file mode 100644 index 0000000..7fff14e --- /dev/null +++ b/apps/api/prisma/migrations/20261107120000_voip_daftareshoma/migration.sql @@ -0,0 +1,10 @@ +-- AlterTable +ALTER TABLE "VoipConnection" ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'generic'; +ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenCiphertext" TEXT; +ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenIv" TEXT; +ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenTag" TEXT; +ALTER TABLE "VoipConnection" ADD COLUMN "fromNumber" TEXT; +ALTER TABLE "VoipConnection" ADD COLUMN "callerExtension" TEXT; + +-- CreateIndex +CREATE INDEX "VoipConnection_organizationId_provider_idx" ON "VoipConnection"("organizationId", "provider"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 132d552..77de609 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -2953,18 +2953,28 @@ model MontifyWebhookDelivery { @@index([connectionId, createdAt]) } -/// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی) +/// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی یا ارائه‌دهنده) model VoipConnection { id String @id @default(cuid()) organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) label String + /// generic | daftareshoma + provider String @default("generic") enabled Boolean @default(true) webhookSecretCiphertext String? webhookSecretIv String? webhookSecretTag String? - /// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند + /// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند (generic) clickToCallUrl String? + /// توکن Bearer API دفتر شما (رمزنگاری‌شده) + apiTokenCiphertext String? + apiTokenIv String? + apiTokenTag String? + /// شماره مبدأ در تماس خروجی دفتر شما (from_number) + fromNumber String? + /// خط/داخلی سازمان در دفتر شما (caller_extension) + callerExtension String? lastEventAt DateTime? lastError String? calls VoipCallLog[] @@ -2972,6 +2982,7 @@ model VoipConnection { updatedAt DateTime @updatedAt @@index([organizationId, enabled]) + @@index([organizationId, provider]) } model VoipCallLog { diff --git a/apps/api/src/integrations/voip/call-summary.service.ts b/apps/api/src/integrations/voip/call-summary.service.ts index a56b83a..5e64746 100644 --- a/apps/api/src/integrations/voip/call-summary.service.ts +++ b/apps/api/src/integrations/voip/call-summary.service.ts @@ -3,6 +3,9 @@ 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; @@ -17,6 +20,8 @@ export class CallSummaryService { private readonly stt: SttClientService, private readonly ai: AiService, private readonly notes: NotesService, + private readonly connections: VoipConnectionsService, + private readonly daftareshoma: DaftaresomaClient, ) {} @Cron('*/1 * * * *') @@ -74,7 +79,31 @@ export class CallSummaryService { ); return; } - transcript = await this.stt.downloadAndTranscribe(row.recordingUrl); + 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 }, diff --git a/apps/api/src/integrations/voip/calls.service.ts b/apps/api/src/integrations/voip/calls.service.ts index e7cfb9a..ccc7f24 100644 --- a/apps/api/src/integrations/voip/calls.service.ts +++ b/apps/api/src/integrations/voip/calls.service.ts @@ -13,6 +13,11 @@ import { PrismaService } from '../../prisma/prisma.service'; import { NotificationsService } from '../../notifications/notifications.service'; import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize'; import { VoipConnectionsService } from './connections.service'; +import { DaftaresomaClient } from './daftareshoma-client'; +import { + daftareshomaRecordingRef, + parseDaftaresomaRecordingRef, +} from './daftareshoma-webhook'; function digitsOnly(value: string | null | undefined): string { return (value ?? '').replace(/\D/g, ''); @@ -57,6 +62,7 @@ export class VoipCallsService { private readonly prisma: PrismaService, private readonly connections: VoipConnectionsService, private readonly notifications: NotificationsService, + private readonly daftareshoma: DaftaresomaClient, ) {} async listRecent( @@ -240,6 +246,17 @@ export class VoipCallsService { await this.notifyIncoming(conn.organizationId, personId, fromNumber); } + if ( + conn.provider === 'daftareshoma' && + ['completed', 'answered', 'missed'].includes(status) + ) { + void this.enrichDaftaresomaRecording(connectionId, callId).catch((e) => { + this.logger.warn( + `daftareshoma recording enrich failed: ${e instanceof Error ? e.message : e}`, + ); + }); + } + const link = await this.prisma.voipCallLog.findUnique({ where: { connectionId_externalCallId: { @@ -261,10 +278,67 @@ export class VoipCallsService { userId: string, number: string, personId?: string, - ): Promise<{ ok: true; mode: 'url' | 'tel'; dialUrl: string }> { + ): Promise<{ ok: true; mode: 'url' | 'tel' | 'daftareshoma'; dialUrl: string }> { const phone = normalizePhone(number) ?? digitsOnly(number); if (!phone) throw new BadRequestException('شماره معتبر نیست'); + const daftareshomaConn = await this.prisma.voipConnection.findFirst({ + where: { + organizationId, + enabled: true, + provider: 'daftareshoma', + }, + orderBy: { createdAt: 'asc' }, + }); + + if (daftareshomaConn) { + const token = await this.connections.decryptApiToken(daftareshomaConn); + const fromNumber = daftareshomaConn.fromNumber?.trim(); + const callerExtension = daftareshomaConn.callerExtension?.trim(); + if (!token || !fromNumber || !callerExtension) { + throw new BadRequestException( + 'اتصال دفتر شما ناقص است؛ توکن، شماره مبدأ و خط را تکمیل کنید', + ); + } + const toE164 = this.toPlusDigits(phone); + const fromE164 = this.toPlusDigits(fromNumber); + const extE164 = this.toPlusDigits(callerExtension); + try { + await this.daftareshoma.outgoingCall(token, { + fromNumber: fromE164, + toNumber: toE164, + callerExtension: extE164, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : 'خطا'; + await this.prisma.voipConnection.update({ + where: { id: daftareshomaConn.id }, + data: { lastError: msg.slice(0, 400) }, + }); + throw new BadRequestException(msg); + } + + await this.prisma.voipCallLog.create({ + data: { + organizationId, + connectionId: daftareshomaConn.id, + externalCallId: `ctc-${Date.now()}-${userId.slice(0, 6)}`, + direction: 'outbound', + status: 'ringing', + fromNumber: fromE164, + toNumber: toE164, + personId: personId ?? null, + startedAt: new Date(), + }, + }); + + return { + ok: true, + mode: 'daftareshoma', + dialUrl: `daftareshoma:${toE164}`, + }; + } + const conn = await this.prisma.voipConnection.findFirst({ where: { organizationId, @@ -318,6 +392,69 @@ export class VoipCallsService { return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` }; } + private toPlusDigits(raw: string): string { + const d = digitsOnly(raw); + if (!d) return raw.trim(); + if (raw.trim().startsWith('+')) return `+${d}`; + if (d.startsWith('98')) return `+${d}`; + if (d.startsWith('0') && d.length >= 10) return `+98${d.slice(1)}`; + if (d.length === 10 && d.startsWith('9')) return `+98${d}`; + return `+${d}`; + } + + private async enrichDaftaresomaRecording( + connectionId: string, + callId: string, + ): Promise { + const conn = await this.prisma.voipConnection.findFirst({ + where: { id: connectionId, provider: 'daftareshoma', enabled: true }, + }); + if (!conn) return; + const log = await this.prisma.voipCallLog.findUnique({ + where: { + connectionId_externalCallId: { connectionId, externalCallId: callId }, + }, + }); + if (!log) return; + if (parseDaftaresomaRecordingRef(log.recordingUrl) || log.recordingUrl) { + if ( + parseDaftaresomaRecordingRef(log.recordingUrl) && + !['done', 'pending', 'processing'].includes(log.summaryStatus) + ) { + await this.prisma.voipCallLog.update({ + where: { id: log.id }, + data: { + summaryStatus: 'pending', + summaryError: null, + summaryNextAttemptAt: new Date(), + }, + }); + } + return; + } + + const token = await this.connections.decryptApiToken(conn); + if (!token) return; + const row = await this.daftareshoma.findCallById(token, callId); + const recordingId = row?.recordingId?.trim(); + if (!recordingId) return; + + const ref = daftareshomaRecordingRef(recordingId); + await this.prisma.voipCallLog.update({ + where: { id: log.id }, + data: { + recordingUrl: ref, + summaryStatus: ['done', 'pending', 'processing'].includes( + log.summaryStatus, + ) + ? log.summaryStatus + : 'pending', + summaryError: null, + summaryNextAttemptAt: new Date(), + }, + }); + } + private shouldCreateActivity(status: string): boolean { return ['answered', 'completed', 'missed', 'failed'].includes(status); } diff --git a/apps/api/src/integrations/voip/connections.service.ts b/apps/api/src/integrations/voip/connections.service.ts index 59deef0..e52efda 100644 --- a/apps/api/src/integrations/voip/connections.service.ts +++ b/apps/api/src/integrations/voip/connections.service.ts @@ -7,12 +7,17 @@ import type { CreateVoipConnectionRequest, UpdateVoipConnectionRequest, VoipConnectionDto, + VoipProvider, } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { createHmac } from 'crypto'; import { PrismaService } from '../../prisma/prisma.service'; import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service'; +function asProvider(value: string | null | undefined): VoipProvider { + return value === 'daftareshoma' ? 'daftareshoma' : 'generic'; +} + @Injectable() export class VoipConnectionsService { constructor( @@ -43,19 +48,42 @@ export class VoipConnectionsService { ): Promise { const label = dto.label?.trim(); if (!label) throw new BadRequestException('نام اتصال الزامی است'); + const provider = asProvider(dto.provider); + if (provider === 'daftareshoma') { + const token = dto.apiToken?.trim(); + if (!token) throw new BadRequestException('توکن API دفتر شما الزامی است'); + if (!dto.callerExtension?.trim()) { + throw new BadRequestException('خط / داخلی سازمان الزامی است'); + } + if (!dto.fromNumber?.trim()) { + throw new BadRequestException( + 'شماره مبدأ (from_number) برای تماس خروجی الزامی است', + ); + } + } + const secret = dto.webhookSecret?.trim(); const secretEnc = secret ? this.crypto.encrypt(secret) : null; - const clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl); + const token = dto.apiToken?.trim(); + const tokenEnc = token ? this.crypto.encrypt(token) : null; + const clickToCallUrl = + provider === 'generic' ? this.normalizeClickUrl(dto.clickToCallUrl) : null; const row = await this.prisma.voipConnection.create({ data: { organizationId, label, + provider, enabled: dto.enabled ?? true, clickToCallUrl, + fromNumber: dto.fromNumber?.trim() || null, + callerExtension: dto.callerExtension?.trim() || null, webhookSecretCiphertext: secretEnc?.ciphertext, webhookSecretIv: secretEnc?.iv, webhookSecretTag: secretEnc?.tag, + apiTokenCiphertext: tokenEnc?.ciphertext, + apiTokenIv: tokenEnc?.iv, + apiTokenTag: tokenEnc?.tag, }, }); return this.toDto(row, 0); @@ -77,6 +105,12 @@ export class VoipConnectionsService { if (dto.clickToCallUrl !== undefined) { data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl); } + if (dto.fromNumber !== undefined) { + data.fromNumber = dto.fromNumber?.trim() || null; + } + if (dto.callerExtension !== undefined) { + data.callerExtension = dto.callerExtension?.trim() || null; + } if (dto.webhookSecret !== undefined) { const wh = dto.webhookSecret?.trim(); if (!wh) { @@ -90,6 +124,19 @@ export class VoipConnectionsService { data.webhookSecretTag = enc.tag; } } + if (dto.apiToken !== undefined) { + const tok = dto.apiToken?.trim(); + if (!tok) { + data.apiTokenCiphertext = null; + data.apiTokenIv = null; + data.apiTokenTag = null; + } else { + const enc = this.crypto.encrypt(tok); + data.apiTokenCiphertext = enc.ciphertext; + data.apiTokenIv = enc.iv; + data.apiTokenTag = enc.tag; + } + } const row = await this.prisma.voipConnection.update({ where: { id: existing.id }, data, @@ -124,6 +171,21 @@ export class VoipConnectionsService { }); } + async decryptApiToken(row: { + apiTokenCiphertext: string | null; + apiTokenIv: string | null; + apiTokenTag: string | null; + }): Promise { + if (!row.apiTokenCiphertext || !row.apiTokenIv || !row.apiTokenTag) { + return null; + } + return this.crypto.decrypt({ + ciphertext: row.apiTokenCiphertext, + iv: row.apiTokenIv, + tag: row.apiTokenTag, + }); + } + verifySignature( rawBody: string | Buffer, signature: string | undefined, @@ -153,9 +215,13 @@ export class VoipConnectionsService { row: { id: string; label: string; + provider?: string | null; enabled: boolean; webhookSecretCiphertext: string | null; + apiTokenCiphertext?: string | null; clickToCallUrl: string | null; + fromNumber?: string | null; + callerExtension?: string | null; lastEventAt: Date | null; lastError: string | null; createdAt: Date; @@ -166,9 +232,13 @@ export class VoipConnectionsService { return { id: row.id, label: row.label, + provider: asProvider(row.provider), enabled: row.enabled, webhookSecretSet: Boolean(row.webhookSecretCiphertext), + apiTokenSet: Boolean(row.apiTokenCiphertext), clickToCallUrl: row.clickToCallUrl, + fromNumber: row.fromNumber ?? null, + callerExtension: row.callerExtension ?? null, lastEventAt: row.lastEventAt?.toISOString() ?? null, lastError: row.lastError, callCount, diff --git a/apps/api/src/integrations/voip/daftareshoma-client.ts b/apps/api/src/integrations/voip/daftareshoma-client.ts new file mode 100644 index 0000000..7bf0e48 --- /dev/null +++ b/apps/api/src/integrations/voip/daftareshoma-client.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger } from '@nestjs/common'; + +const BASE_URL = 'https://coreapi.daftareshoma.com/api/Customize'; +const MAX_AUDIO_BYTES = 8 * 1024 * 1024; + +export type DaftaresomaOutgoingCallInput = { + fromNumber: string; + toNumber: string; + callerExtension: string; +}; + +export type DaftaresomaCallSearchRow = { + id?: string; + fromNumber?: string; + toNumber?: string; + recordingId?: string | null; + direction?: string; + reportDispositionType?: string; + startTime?: string; + endTime?: string; + duration?: string; + extension?: string; +}; + +@Injectable() +export class DaftaresomaClient { + private readonly logger = new Logger(DaftaresomaClient.name); + + async outgoingCall( + apiToken: string, + input: DaftaresomaOutgoingCallInput, + ): Promise { + const res = await fetch(`${BASE_URL}/OutgoingCall`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + from_number: input.fromNumber, + to_number: input.toNumber, + caller_extension: input.callerExtension, + }), + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + this.logger.warn( + `OutgoingCall failed (${res.status}): ${text.slice(0, 300)}`, + ); + throw new Error( + text.trim() + ? `تماس خروجی دفتر شما ناموفق بود (${res.status}): ${text.slice(0, 200)}` + : `تماس خروجی دفتر شما ناموفق بود (${res.status})`, + ); + } + } + + async findCallById( + apiToken: string, + callId: string, + ): Promise { + const res = await fetch(`${BASE_URL}/CustomerCallSearch`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ id: callId, limit: 1, pagination: 1 }), + signal: AbortSignal.timeout(20000), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + this.logger.warn( + `CustomerCallSearch failed (${res.status}): ${text.slice(0, 200)}`, + ); + return null; + } + const json = (await res.json()) as { + data?: DaftaresomaCallSearchRow[]; + }; + const row = Array.isArray(json.data) ? json.data[0] : null; + return row ?? null; + } + + async downloadRecording( + apiToken: string, + recordingId: string, + ): Promise<{ buffer: ArrayBuffer; contentType: string; filename: string }> { + const url = new URL(`${BASE_URL}/GetCallRecording`); + url.searchParams.set('recordingId', recordingId); + const res = await fetch(url.toString(), { + method: 'GET', + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: '*/*', + 'User-Agent': 'MontactCRM-VoIP/1.0', + }, + signal: AbortSignal.timeout(60000), + redirect: 'follow', + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `دانلود ضبط دفتر شما ناموفق بود (${res.status})${text ? `: ${text.slice(0, 160)}` : ''}`, + ); + } + const contentLength = Number(res.headers.get('content-length') || 0); + if (contentLength > MAX_AUDIO_BYTES) { + throw new Error('حجم فایل ضبط بیش از حد مجاز است'); + } + const buffer = await res.arrayBuffer(); + if (buffer.byteLength > MAX_AUDIO_BYTES) { + throw new Error('حجم فایل ضبط بیش از حد مجاز است'); + } + if (!buffer.byteLength) { + throw new Error('فایل ضبط خالی است'); + } + const contentType = + res.headers.get('content-type')?.split(';')[0]?.trim() || 'audio/wav'; + const filename = `daftareshoma-${recordingId}.wav`; + return { buffer, contentType, filename }; + } +} diff --git a/apps/api/src/integrations/voip/daftareshoma-webhook.ts b/apps/api/src/integrations/voip/daftareshoma-webhook.ts new file mode 100644 index 0000000..054e1c1 --- /dev/null +++ b/apps/api/src/integrations/voip/daftareshoma-webhook.ts @@ -0,0 +1,156 @@ +import type { VoipWebhookEvent } from '@mento/shared'; + +/** پیشوند ذخیره‌شده در recordingUrl برای واکشی بعدی با توکن API */ +export const DAFTARESHOMA_RECORDING_PREFIX = 'daftareshoma-recording:'; + +export function daftareshomaRecordingRef(recordingId: string): string { + return `${DAFTARESHOMA_RECORDING_PREFIX}${recordingId}`; +} + +export function parseDaftaresomaRecordingRef( + value: string | null | undefined, +): string | null { + const raw = String(value ?? '').trim(); + if (!raw.startsWith(DAFTARESHOMA_RECORDING_PREFIX)) return null; + const id = raw.slice(DAFTARESHOMA_RECORDING_PREFIX.length).trim(); + return id || null; +} + +type Loose = Record; + +function str(v: unknown): string { + return v == null ? '' : String(v).trim(); +} + +function pick(obj: Loose, ...keys: string[]): string { + for (const k of keys) { + const v = str(obj[k]); + if (v) return v; + } + return ''; +} + +function parseDurationSec(raw: unknown): number | undefined { + if (raw == null || raw === '') return undefined; + if (typeof raw === 'number' && Number.isFinite(raw)) { + return Math.max(0, Math.round(raw)); + } + const s = String(raw).trim(); + if (!s) return undefined; + if (/^\d+(\.\d+)?$/.test(s)) return Math.max(0, Math.round(Number(s))); + const parts = s.split(':').map((p) => Number(p)); + if (parts.some((n) => !Number.isFinite(n))) return undefined; + if (parts.length === 3) { + return Math.max(0, Math.round(parts[0]! * 3600 + parts[1]! * 60 + parts[2]!)); + } + if (parts.length === 2) { + return Math.max(0, Math.round(parts[0]! * 60 + parts[1]!)); + } + return undefined; +} + +/** + * رویداد وب‌هوک دفتر شما را به VoipWebhookEvent تبدیل می‌کند. + * اگر payload از قبل فرمت Montact باشد، همان را برمی‌گرداند. + */ +export function mapDaftaresomaWebhook( + body: unknown, +): VoipWebhookEvent | null { + if (!body || typeof body !== 'object') return null; + const obj = body as Loose; + + const legacyCallId = pick(obj, 'callId'); + if (legacyCallId && (obj.direction || obj.status || obj.from || obj.to)) { + return body as VoipWebhookEvent; + } + + const eventType = pick(obj, 'EventType', 'eventType', 'event'); + const callId = pick(obj, 'CallId', 'callId', 'Id', 'id'); + if (!callId) return null; + + const caller = pick( + obj, + 'CallerNumber', + 'callerNumber', + 'from', + 'From', + 'fromNumber', + ); + const callee = pick( + obj, + 'TransferredToNumber', + 'transferredToNumber', + 'CalleeExtension', + 'calleeExtension', + 'to', + 'To', + 'toNumber', + ); + const startedAt = + pick(obj, 'StartDateTime', 'startDateTime', 'startedAt') || undefined; + const endedAt = + pick(obj, 'EndDateTime', 'endDateTime', 'endedAt', 'DateTime') || + undefined; + const durationSec = parseDurationSec( + obj.DurationInSeconds ?? obj.durationInSeconds ?? obj.durationSec, + ); + + const lower = eventType.toLowerCase(); + const isOutbound = lower.includes('outgoing') || lower.includes('outbound'); + const isStarted = + lower.includes('started') || + lower.includes('ringing') || + lower.includes('transferstarted'); + const isEnded = + lower.includes('ended') || + lower.includes('completed') || + lower.includes('hangup'); + + let status: string = 'completed'; + if (isStarted && !isEnded) { + status = 'ringing'; + } else if (isEnded) { + const result = obj.Result ?? obj.result; + if (result === false || result === 'false' || result === 0) { + status = durationSec && durationSec > 0 ? 'completed' : 'missed'; + } else if (result === true || result === 'true' || result === 1) { + status = 'completed'; + } else { + status = 'completed'; + } + } + + const recordingId = pick( + obj, + 'RecordingId', + 'recordingId', + 'RecordId', + 'recordId', + ); + + const event: VoipWebhookEvent = { + callId, + direction: isOutbound ? 'outbound' : 'inbound', + status, + from: caller || undefined, + to: callee || undefined, + startedAt, + endedAt, + durationSec, + caller: caller || undefined, + callee: callee || undefined, + }; + if (recordingId) { + event.recordingUrl = daftareshomaRecordingRef(recordingId); + } + return event; +} + +export function isDaftaresomaWebhookPayload(body: unknown): boolean { + if (!body || typeof body !== 'object') return false; + const obj = body as Loose; + return Boolean( + pick(obj, 'EventType', 'eventType') || + (pick(obj, 'CallId') && pick(obj, 'CallerNumber', 'callerNumber')), + ); +} diff --git a/apps/api/src/integrations/voip/dto.ts b/apps/api/src/integrations/voip/dto.ts index 4caf909..9c63a88 100644 --- a/apps/api/src/integrations/voip/dto.ts +++ b/apps/api/src/integrations/voip/dto.ts @@ -1,5 +1,6 @@ import { IsBoolean, + IsIn, IsOptional, IsString, MaxLength, @@ -12,6 +13,10 @@ export class CreateVoipConnectionDto { @MaxLength(80) label!: string; + @IsOptional() + @IsIn(['generic', 'daftareshoma']) + provider?: 'generic' | 'daftareshoma'; + @IsOptional() @IsString() @MaxLength(200) @@ -22,6 +27,21 @@ export class CreateVoipConnectionDto { @MaxLength(500) clickToCallUrl?: string | null; + @IsOptional() + @IsString() + @MaxLength(500) + apiToken?: string; + + @IsOptional() + @IsString() + @MaxLength(40) + fromNumber?: string | null; + + @IsOptional() + @IsString() + @MaxLength(40) + callerExtension?: string | null; + @IsOptional() @IsBoolean() enabled?: boolean; @@ -44,6 +64,21 @@ export class UpdateVoipConnectionDto { @MaxLength(500) clickToCallUrl?: string | null; + @IsOptional() + @IsString() + @MaxLength(500) + apiToken?: string | null; + + @IsOptional() + @IsString() + @MaxLength(40) + fromNumber?: string | null; + + @IsOptional() + @IsString() + @MaxLength(40) + callerExtension?: string | null; + @IsOptional() @IsBoolean() enabled?: boolean; diff --git a/apps/api/src/integrations/voip/stt-client.service.ts b/apps/api/src/integrations/voip/stt-client.service.ts index 092bc05..1897887 100644 --- a/apps/api/src/integrations/voip/stt-client.service.ts +++ b/apps/api/src/integrations/voip/stt-client.service.ts @@ -18,17 +18,31 @@ export class SttClientService { throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست'); } + const timeoutMs = Number( + this.config.get('STT_TIMEOUT_MS') || 180000, + ); + const audio = await this.downloadAudio(recordingUrl, timeoutMs); + return this.transcribeAudio(audio, timeoutMs); + } + + async transcribeAudio( + audio: { buffer: ArrayBuffer; contentType: string; filename: string }, + timeoutMs?: number, + ): Promise { + if (!this.isEnabled()) { + throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست'); + } + const baseUrl = this.config.get('STT_URL')?.trim(); if (!baseUrl) { throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده'); } - const timeoutMs = Number( + const waitMs = 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, @@ -39,7 +53,7 @@ export class SttClientService { if (token) headers['X-STT-Token'] = token; const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + const timer = setTimeout(() => controller.abort(), waitMs); try { const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, { method: 'POST', diff --git a/apps/api/src/integrations/voip/voip.controller.ts b/apps/api/src/integrations/voip/voip.controller.ts index c0fecd5..bb2e7af 100644 --- a/apps/api/src/integrations/voip/voip.controller.ts +++ b/apps/api/src/integrations/voip/voip.controller.ts @@ -28,6 +28,10 @@ import { UpdateVoipConnectionDto, VoipClickToCallDto, } from './dto'; +import { + isDaftaresomaWebhookPayload, + mapDaftaresomaWebhook, +} from './daftareshoma-webhook'; @Controller('integrations/voip') @UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard) @@ -129,11 +133,21 @@ export class VoipWebhookController { if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است'); } - if (!body?.callId) { + let event: VoipWebhookEvent | null = null; + if ( + row.provider === 'daftareshoma' || + isDaftaresomaWebhookPayload(body) + ) { + event = mapDaftaresomaWebhook(body); + } else if (body?.callId) { + event = body; + } + + if (!event?.callId) { return { ok: false, error: 'callId required' }; } - const result = await this.calls.ingestWebhook(connectionId, body); + const result = await this.calls.ingestWebhook(connectionId, event); return { ok: true, ...result }; } } diff --git a/apps/api/src/integrations/voip/voip.module.ts b/apps/api/src/integrations/voip/voip.module.ts index 8bda5d1..d48bf32 100644 --- a/apps/api/src/integrations/voip/voip.module.ts +++ b/apps/api/src/integrations/voip/voip.module.ts @@ -7,6 +7,7 @@ import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service'; import { CallSummaryService } from './call-summary.service'; import { VoipConnectionsService } from './connections.service'; import { VoipCallsService } from './calls.service'; +import { DaftaresomaClient } from './daftareshoma-client'; import { SttClientService } from './stt-client.service'; import { VoipController, VoipWebhookController } from './voip.controller'; @@ -17,6 +18,7 @@ import { VoipController, VoipWebhookController } from './voip.controller'; TahesabCryptoService, VoipConnectionsService, VoipCallsService, + DaftaresomaClient, SttClientService, CallSummaryService, ], diff --git a/apps/web/src/views/VoipSettingsView.vue b/apps/web/src/views/VoipSettingsView.vue index df29f72..9762ea2 100644 --- a/apps/web/src/views/VoipSettingsView.vue +++ b/apps/web/src/views/VoipSettingsView.vue @@ -20,12 +20,15 @@ import { import UiIcon from '@/components/UiIcon.vue'; import { formatJalaliNumeric, rewriteGregorianDates, toFaDigits } from '@/lib/jalali'; +type TabKey = 'generic' | 'daftareshoma'; + const loading = ref(false); const saving = ref(false); const error = ref(''); const ok = ref(''); const items = ref([]); const calls = ref([]); +const tab = ref('daftareshoma'); const form = reactive({ label: 'سانترال اصلی', @@ -34,12 +37,34 @@ const form = reactive({ enabled: true, }); -const webhookUrl = computed(() => { - if (!items.value[0]) return ''; - const origin = typeof window !== 'undefined' ? window.location.origin : ''; - return `${origin}/api/public/integrations/voip/${items.value[0].id}/webhook`; +const daftaForm = reactive({ + label: 'دفتر شما', + apiToken: '', + fromNumber: '', + callerExtension: '', + enabled: true, }); +const genericItems = computed(() => + items.value.filter((c) => c.provider !== 'daftareshoma'), +); +const daftaItems = computed(() => + items.value.filter((c) => c.provider === 'daftareshoma'), +); + +const activeDafta = computed(() => daftaItems.value[0] ?? null); + +const webhookUrlFor = (id: string | undefined) => { + if (!id) return ''; + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + return `${origin}/api/public/integrations/voip/${id}/webhook`; +}; + +const genericWebhookUrl = computed(() => + webhookUrlFor(genericItems.value[0]?.id), +); +const daftaWebhookUrl = computed(() => webhookUrlFor(activeDafta.value?.id)); + const statusLabel: Record = { ringing: 'زنگ', answered: 'پاسخ', @@ -73,6 +98,7 @@ async function load() { ]); items.value = connRes.data; calls.value = callRes.data; + fillDaftaFromActive(); } catch (e) { if (axios.isAxiosError(e) && e.response?.status === 403) { error.value = 'ماژول تلفن سازمانی برای این محیط فعال نیست.'; @@ -84,16 +110,18 @@ async function load() { } } -async function create() { +async function createGeneric() { if (!form.label.trim()) { error.value = 'نام اتصال را وارد کنید.'; return; } saving.value = true; error.value = ''; + ok.value = ''; try { const payload: CreateVoipConnectionRequest = { label: form.label.trim(), + provider: 'generic', webhookSecret: form.webhookSecret.trim() || undefined, clickToCallUrl: form.clickToCallUrl.trim() || null, enabled: form.enabled, @@ -111,6 +139,69 @@ async function create() { } } +async function saveDafta() { + if (!daftaForm.label.trim()) { + error.value = 'نام اتصال را وارد کنید.'; + return; + } + if (!activeDafta.value && !daftaForm.apiToken.trim()) { + error.value = 'توکن API دفتر شما را وارد کنید.'; + return; + } + if (!daftaForm.fromNumber.trim() || !daftaForm.callerExtension.trim()) { + error.value = 'شماره مبدأ و خط / داخلی را وارد کنید.'; + return; + } + saving.value = true; + error.value = ''; + ok.value = ''; + try { + if (activeDafta.value) { + await api.patch(`/integrations/voip/connections/${activeDafta.value.id}`, { + label: daftaForm.label.trim(), + fromNumber: daftaForm.fromNumber.trim(), + callerExtension: daftaForm.callerExtension.trim(), + enabled: daftaForm.enabled, + ...(daftaForm.apiToken.trim() + ? { apiToken: daftaForm.apiToken.trim() } + : {}), + }); + daftaForm.apiToken = ''; + ok.value = 'اتصال دفتر شما بروزرسانی شد.'; + } else { + const payload: CreateVoipConnectionRequest = { + label: daftaForm.label.trim(), + provider: 'daftareshoma', + apiToken: daftaForm.apiToken.trim(), + fromNumber: daftaForm.fromNumber.trim(), + callerExtension: daftaForm.callerExtension.trim(), + enabled: daftaForm.enabled, + }; + await api.post('/integrations/voip/connections', payload); + daftaForm.apiToken = ''; + ok.value = + 'اتصال دفتر شما ذخیره شد. آدرس وب‌هوک را در پرتال دفتر شما ثبت کنید.'; + } + await load(); + } catch (e) { + error.value = axios.isAxiosError(e) + ? String((e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.') + : 'ذخیره ناموفق بود.'; + } finally { + saving.value = false; + } +} + +function fillDaftaFromActive() { + const c = activeDafta.value; + if (!c) return; + daftaForm.label = c.label; + daftaForm.fromNumber = c.fromNumber ?? ''; + daftaForm.callerExtension = c.callerExtension ?? ''; + daftaForm.enabled = c.enabled; + daftaForm.apiToken = ''; +} + async function remove(id: string) { if (!confirm('این اتصال حذف شود؟')) return; try { @@ -121,18 +212,19 @@ async function remove(id: string) { } } -async function copyWebhook() { - if (!webhookUrl.value) return; +async function copyText(text: string, msg: string) { + if (!text) return; try { - await navigator.clipboard.writeText(webhookUrl.value); - ok.value = 'آدرس وب‌هوک کپی شد.'; + await navigator.clipboard.writeText(text); + ok.value = msg; } catch { error.value = 'کپی ممکن نشد.'; } } -onMounted(() => { - void load(); +onMounted(async () => { + await load(); + fillDaftaFromActive(); }); @@ -150,79 +242,189 @@ onMounted(() => { + +

{{ error }}

{{ ok }}

- -

- سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وب‌هوک بفرستد. - فیلدهای اصلی: - callId، - direction (inbound/outbound)، - status، - from، - to. - برای خلاصه خودکار یکی از - recordingUrl - (لینک فایل ضبط) یا - transcript - (متن آماده) را بفرستید؛ پس از - completed - / - answered - صف خلاصه ساخته می‌شود. - امضا اختیاری با هدر - X-Voip-Signature - (HMAC-SHA256 هگز). -

-
- - - - - - - - - -
- ذخیره اتصال -
+ + + { display: grid; gap: 1rem; } +.tabs { + display: flex; + justify-content: flex-start; + gap: 1.75rem; + border-bottom: 1px solid #e2e8f0; +} +.tabs button { + appearance: none; + background: none; + border: 0; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + padding: 0.55rem 0.15rem; + color: #94a3b8; + font: inherit; + font-weight: 600; + cursor: pointer; +} +.tabs button.on { + color: #0d9488; + border-bottom-color: #14b8a6; +} .hint, .muted { color: #64748b; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 182f11d..c4dfcc9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5511,13 +5511,21 @@ export type VoipCallStatus = | "completed" | "failed"; +export type VoipProvider = "generic" | "daftareshoma"; + export interface VoipConnectionDto { id: string; label: string; + provider: VoipProvider; enabled: boolean; webhookSecretSet: boolean; + apiTokenSet: boolean; /** قالب URL برای شروع تماس؛ {number} جایگزین می‌شود */ clickToCallUrl: string | null; + /** شماره مبدأ تماس خروجی (دفتر شما) */ + fromNumber: string | null; + /** خط/داخلی سازمان (دفتر شما) */ + callerExtension: string | null; lastEventAt: string | null; lastError: string | null; callCount: number; @@ -5527,8 +5535,13 @@ export interface VoipConnectionDto { export interface CreateVoipConnectionRequest { label: string; + provider?: VoipProvider; webhookSecret?: string; clickToCallUrl?: string | null; + /** توکن Bearer API دفتر شما */ + apiToken?: string; + fromNumber?: string | null; + callerExtension?: string | null; enabled?: boolean; } @@ -5536,6 +5549,9 @@ export interface UpdateVoipConnectionRequest { label?: string; webhookSecret?: string | null; clickToCallUrl?: string | null; + apiToken?: string | null; + fromNumber?: string | null; + callerExtension?: string | null; enabled?: boolean; }