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>
This commit is contained in:
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<VoipConnectionDto> {
|
||||
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<string | null> {
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<DaftaresomaCallSearchRow | null> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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')),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -18,17 +18,31 @@ export class SttClientService {
|
||||
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
|
||||
}
|
||||
|
||||
const timeoutMs = Number(
|
||||
this.config.get<string>('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<string> {
|
||||
if (!this.isEnabled()) {
|
||||
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
|
||||
}
|
||||
|
||||
const baseUrl = this.config.get<string>('STT_URL')?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
|
||||
}
|
||||
|
||||
const timeoutMs = Number(
|
||||
const waitMs = timeoutMs ?? Number(
|
||||
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
|
||||
);
|
||||
const token = this.config.get<string>('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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user