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])
|
@@index([connectionId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// اتصال تلفن سازمانی / سانترال (وبهوک عمومی)
|
/// اتصال تلفن سازمانی / سانترال (وبهوک عمومی یا ارائهدهنده)
|
||||||
model VoipConnection {
|
model VoipConnection {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
label String
|
label String
|
||||||
|
/// generic | daftareshoma
|
||||||
|
provider String @default("generic")
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
webhookSecretCiphertext String?
|
webhookSecretCiphertext String?
|
||||||
webhookSecretIv String?
|
webhookSecretIv String?
|
||||||
webhookSecretTag String?
|
webhookSecretTag String?
|
||||||
/// قالب URL شروع تماس؛ جای {number} شماره مینشیند
|
/// قالب URL شروع تماس؛ جای {number} شماره مینشیند (generic)
|
||||||
clickToCallUrl String?
|
clickToCallUrl String?
|
||||||
|
/// توکن Bearer API دفتر شما (رمزنگاریشده)
|
||||||
|
apiTokenCiphertext String?
|
||||||
|
apiTokenIv String?
|
||||||
|
apiTokenTag String?
|
||||||
|
/// شماره مبدأ در تماس خروجی دفتر شما (from_number)
|
||||||
|
fromNumber String?
|
||||||
|
/// خط/داخلی سازمان در دفتر شما (caller_extension)
|
||||||
|
callerExtension String?
|
||||||
lastEventAt DateTime?
|
lastEventAt DateTime?
|
||||||
lastError String?
|
lastError String?
|
||||||
calls VoipCallLog[]
|
calls VoipCallLog[]
|
||||||
@@ -2972,6 +2982,7 @@ model VoipConnection {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([organizationId, enabled])
|
@@index([organizationId, enabled])
|
||||||
|
@@index([organizationId, provider])
|
||||||
}
|
}
|
||||||
|
|
||||||
model VoipCallLog {
|
model VoipCallLog {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule';
|
|||||||
import { AiService } from '../../ai/ai.service';
|
import { AiService } from '../../ai/ai.service';
|
||||||
import { NotesService } from '../../notes/notes.service';
|
import { NotesService } from '../../notes/notes.service';
|
||||||
import { PrismaService } from '../../prisma/prisma.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';
|
import { SttClientService } from './stt-client.service';
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 3;
|
const MAX_ATTEMPTS = 3;
|
||||||
@@ -17,6 +20,8 @@ export class CallSummaryService {
|
|||||||
private readonly stt: SttClientService,
|
private readonly stt: SttClientService,
|
||||||
private readonly ai: AiService,
|
private readonly ai: AiService,
|
||||||
private readonly notes: NotesService,
|
private readonly notes: NotesService,
|
||||||
|
private readonly connections: VoipConnectionsService,
|
||||||
|
private readonly daftareshoma: DaftaresomaClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron('*/1 * * * *')
|
@Cron('*/1 * * * *')
|
||||||
@@ -74,7 +79,31 @@ export class CallSummaryService {
|
|||||||
);
|
);
|
||||||
return;
|
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({
|
await this.prisma.voipCallLog.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { transcript },
|
data: { transcript },
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import { PrismaService } from '../../prisma/prisma.service';
|
|||||||
import { NotificationsService } from '../../notifications/notifications.service';
|
import { NotificationsService } from '../../notifications/notifications.service';
|
||||||
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
|
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
|
||||||
import { VoipConnectionsService } from './connections.service';
|
import { VoipConnectionsService } from './connections.service';
|
||||||
|
import { DaftaresomaClient } from './daftareshoma-client';
|
||||||
|
import {
|
||||||
|
daftareshomaRecordingRef,
|
||||||
|
parseDaftaresomaRecordingRef,
|
||||||
|
} from './daftareshoma-webhook';
|
||||||
|
|
||||||
function digitsOnly(value: string | null | undefined): string {
|
function digitsOnly(value: string | null | undefined): string {
|
||||||
return (value ?? '').replace(/\D/g, '');
|
return (value ?? '').replace(/\D/g, '');
|
||||||
@@ -57,6 +62,7 @@ export class VoipCallsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly connections: VoipConnectionsService,
|
private readonly connections: VoipConnectionsService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly daftareshoma: DaftaresomaClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listRecent(
|
async listRecent(
|
||||||
@@ -240,6 +246,17 @@ export class VoipCallsService {
|
|||||||
await this.notifyIncoming(conn.organizationId, personId, fromNumber);
|
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({
|
const link = await this.prisma.voipCallLog.findUnique({
|
||||||
where: {
|
where: {
|
||||||
connectionId_externalCallId: {
|
connectionId_externalCallId: {
|
||||||
@@ -261,10 +278,67 @@ export class VoipCallsService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
number: string,
|
number: string,
|
||||||
personId?: 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);
|
const phone = normalizePhone(number) ?? digitsOnly(number);
|
||||||
if (!phone) throw new BadRequestException('شماره معتبر نیست');
|
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({
|
const conn = await this.prisma.voipConnection.findFirst({
|
||||||
where: {
|
where: {
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -318,6 +392,69 @@ export class VoipCallsService {
|
|||||||
return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` };
|
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 {
|
private shouldCreateActivity(status: string): boolean {
|
||||||
return ['answered', 'completed', 'missed', 'failed'].includes(status);
|
return ['answered', 'completed', 'missed', 'failed'].includes(status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ import type {
|
|||||||
CreateVoipConnectionRequest,
|
CreateVoipConnectionRequest,
|
||||||
UpdateVoipConnectionRequest,
|
UpdateVoipConnectionRequest,
|
||||||
VoipConnectionDto,
|
VoipConnectionDto,
|
||||||
|
VoipProvider,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { createHmac } from 'crypto';
|
import { createHmac } from 'crypto';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
|
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
|
||||||
|
|
||||||
|
function asProvider(value: string | null | undefined): VoipProvider {
|
||||||
|
return value === 'daftareshoma' ? 'daftareshoma' : 'generic';
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VoipConnectionsService {
|
export class VoipConnectionsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -43,19 +48,42 @@ export class VoipConnectionsService {
|
|||||||
): Promise<VoipConnectionDto> {
|
): Promise<VoipConnectionDto> {
|
||||||
const label = dto.label?.trim();
|
const label = dto.label?.trim();
|
||||||
if (!label) throw new BadRequestException('نام اتصال الزامی است');
|
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 secret = dto.webhookSecret?.trim();
|
||||||
const secretEnc = secret ? this.crypto.encrypt(secret) : null;
|
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({
|
const row = await this.prisma.voipConnection.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId,
|
organizationId,
|
||||||
label,
|
label,
|
||||||
|
provider,
|
||||||
enabled: dto.enabled ?? true,
|
enabled: dto.enabled ?? true,
|
||||||
clickToCallUrl,
|
clickToCallUrl,
|
||||||
|
fromNumber: dto.fromNumber?.trim() || null,
|
||||||
|
callerExtension: dto.callerExtension?.trim() || null,
|
||||||
webhookSecretCiphertext: secretEnc?.ciphertext,
|
webhookSecretCiphertext: secretEnc?.ciphertext,
|
||||||
webhookSecretIv: secretEnc?.iv,
|
webhookSecretIv: secretEnc?.iv,
|
||||||
webhookSecretTag: secretEnc?.tag,
|
webhookSecretTag: secretEnc?.tag,
|
||||||
|
apiTokenCiphertext: tokenEnc?.ciphertext,
|
||||||
|
apiTokenIv: tokenEnc?.iv,
|
||||||
|
apiTokenTag: tokenEnc?.tag,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return this.toDto(row, 0);
|
return this.toDto(row, 0);
|
||||||
@@ -77,6 +105,12 @@ export class VoipConnectionsService {
|
|||||||
if (dto.clickToCallUrl !== undefined) {
|
if (dto.clickToCallUrl !== undefined) {
|
||||||
data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
|
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) {
|
if (dto.webhookSecret !== undefined) {
|
||||||
const wh = dto.webhookSecret?.trim();
|
const wh = dto.webhookSecret?.trim();
|
||||||
if (!wh) {
|
if (!wh) {
|
||||||
@@ -90,6 +124,19 @@ export class VoipConnectionsService {
|
|||||||
data.webhookSecretTag = enc.tag;
|
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({
|
const row = await this.prisma.voipConnection.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data,
|
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(
|
verifySignature(
|
||||||
rawBody: string | Buffer,
|
rawBody: string | Buffer,
|
||||||
signature: string | undefined,
|
signature: string | undefined,
|
||||||
@@ -153,9 +215,13 @@ export class VoipConnectionsService {
|
|||||||
row: {
|
row: {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
provider?: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
webhookSecretCiphertext: string | null;
|
webhookSecretCiphertext: string | null;
|
||||||
|
apiTokenCiphertext?: string | null;
|
||||||
clickToCallUrl: string | null;
|
clickToCallUrl: string | null;
|
||||||
|
fromNumber?: string | null;
|
||||||
|
callerExtension?: string | null;
|
||||||
lastEventAt: Date | null;
|
lastEventAt: Date | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -166,9 +232,13 @@ export class VoipConnectionsService {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
label: row.label,
|
label: row.label,
|
||||||
|
provider: asProvider(row.provider),
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
webhookSecretSet: Boolean(row.webhookSecretCiphertext),
|
webhookSecretSet: Boolean(row.webhookSecretCiphertext),
|
||||||
|
apiTokenSet: Boolean(row.apiTokenCiphertext),
|
||||||
clickToCallUrl: row.clickToCallUrl,
|
clickToCallUrl: row.clickToCallUrl,
|
||||||
|
fromNumber: row.fromNumber ?? null,
|
||||||
|
callerExtension: row.callerExtension ?? null,
|
||||||
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||||
lastError: row.lastError,
|
lastError: row.lastError,
|
||||||
callCount,
|
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 {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
@@ -12,6 +13,10 @@ export class CreateVoipConnectionDto {
|
|||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
label!: string;
|
label!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['generic', 'daftareshoma'])
|
||||||
|
provider?: 'generic' | 'daftareshoma';
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
@@ -22,6 +27,21 @@ export class CreateVoipConnectionDto {
|
|||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
clickToCallUrl?: string | null;
|
clickToCallUrl?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
apiToken?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
fromNumber?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
callerExtension?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -44,6 +64,21 @@ export class UpdateVoipConnectionDto {
|
|||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
clickToCallUrl?: string | null;
|
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()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|||||||
@@ -18,17 +18,31 @@ export class SttClientService {
|
|||||||
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
|
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();
|
const baseUrl = this.config.get<string>('STT_URL')?.trim();
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
|
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutMs = Number(
|
const waitMs = timeoutMs ?? Number(
|
||||||
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
|
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
|
||||||
);
|
);
|
||||||
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || '';
|
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || '';
|
||||||
|
|
||||||
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
const blob = new Blob([new Uint8Array(audio.buffer)], {
|
const blob = new Blob([new Uint8Array(audio.buffer)], {
|
||||||
type: audio.contentType,
|
type: audio.contentType,
|
||||||
@@ -39,7 +53,7 @@ export class SttClientService {
|
|||||||
if (token) headers['X-STT-Token'] = token;
|
if (token) headers['X-STT-Token'] = token;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), waitMs);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, {
|
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import {
|
|||||||
UpdateVoipConnectionDto,
|
UpdateVoipConnectionDto,
|
||||||
VoipClickToCallDto,
|
VoipClickToCallDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
|
import {
|
||||||
|
isDaftaresomaWebhookPayload,
|
||||||
|
mapDaftaresomaWebhook,
|
||||||
|
} from './daftareshoma-webhook';
|
||||||
|
|
||||||
@Controller('integrations/voip')
|
@Controller('integrations/voip')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||||
@@ -129,11 +133,21 @@ export class VoipWebhookController {
|
|||||||
if (!ok) throw new UnauthorizedException('امضای وبهوک نامعتبر است');
|
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' };
|
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 };
|
return { ok: true, ...result };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
|
|||||||
import { CallSummaryService } from './call-summary.service';
|
import { CallSummaryService } from './call-summary.service';
|
||||||
import { VoipConnectionsService } from './connections.service';
|
import { VoipConnectionsService } from './connections.service';
|
||||||
import { VoipCallsService } from './calls.service';
|
import { VoipCallsService } from './calls.service';
|
||||||
|
import { DaftaresomaClient } from './daftareshoma-client';
|
||||||
import { SttClientService } from './stt-client.service';
|
import { SttClientService } from './stt-client.service';
|
||||||
import { VoipController, VoipWebhookController } from './voip.controller';
|
import { VoipController, VoipWebhookController } from './voip.controller';
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ import { VoipController, VoipWebhookController } from './voip.controller';
|
|||||||
TahesabCryptoService,
|
TahesabCryptoService,
|
||||||
VoipConnectionsService,
|
VoipConnectionsService,
|
||||||
VoipCallsService,
|
VoipCallsService,
|
||||||
|
DaftaresomaClient,
|
||||||
SttClientService,
|
SttClientService,
|
||||||
CallSummaryService,
|
CallSummaryService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ import {
|
|||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
import { formatJalaliNumeric, rewriteGregorianDates, toFaDigits } from '@/lib/jalali';
|
import { formatJalaliNumeric, rewriteGregorianDates, toFaDigits } from '@/lib/jalali';
|
||||||
|
|
||||||
|
type TabKey = 'generic' | 'daftareshoma';
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const ok = ref('');
|
const ok = ref('');
|
||||||
const items = ref<VoipConnectionDto[]>([]);
|
const items = ref<VoipConnectionDto[]>([]);
|
||||||
const calls = ref<VoipCallLogDto[]>([]);
|
const calls = ref<VoipCallLogDto[]>([]);
|
||||||
|
const tab = ref<TabKey>('daftareshoma');
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
label: 'سانترال اصلی',
|
label: 'سانترال اصلی',
|
||||||
@@ -34,12 +37,34 @@ const form = reactive({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const webhookUrl = computed(() => {
|
const daftaForm = reactive({
|
||||||
if (!items.value[0]) return '';
|
label: 'دفتر شما',
|
||||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
apiToken: '',
|
||||||
return `${origin}/api/public/integrations/voip/${items.value[0].id}/webhook`;
|
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<string, string> = {
|
const statusLabel: Record<string, string> = {
|
||||||
ringing: 'زنگ',
|
ringing: 'زنگ',
|
||||||
answered: 'پاسخ',
|
answered: 'پاسخ',
|
||||||
@@ -73,6 +98,7 @@ async function load() {
|
|||||||
]);
|
]);
|
||||||
items.value = connRes.data;
|
items.value = connRes.data;
|
||||||
calls.value = callRes.data;
|
calls.value = callRes.data;
|
||||||
|
fillDaftaFromActive();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||||
error.value = 'ماژول تلفن سازمانی برای این محیط فعال نیست.';
|
error.value = 'ماژول تلفن سازمانی برای این محیط فعال نیست.';
|
||||||
@@ -84,16 +110,18 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create() {
|
async function createGeneric() {
|
||||||
if (!form.label.trim()) {
|
if (!form.label.trim()) {
|
||||||
error.value = 'نام اتصال را وارد کنید.';
|
error.value = 'نام اتصال را وارد کنید.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
|
ok.value = '';
|
||||||
try {
|
try {
|
||||||
const payload: CreateVoipConnectionRequest = {
|
const payload: CreateVoipConnectionRequest = {
|
||||||
label: form.label.trim(),
|
label: form.label.trim(),
|
||||||
|
provider: 'generic',
|
||||||
webhookSecret: form.webhookSecret.trim() || undefined,
|
webhookSecret: form.webhookSecret.trim() || undefined,
|
||||||
clickToCallUrl: form.clickToCallUrl.trim() || null,
|
clickToCallUrl: form.clickToCallUrl.trim() || null,
|
||||||
enabled: form.enabled,
|
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) {
|
async function remove(id: string) {
|
||||||
if (!confirm('این اتصال حذف شود؟')) return;
|
if (!confirm('این اتصال حذف شود؟')) return;
|
||||||
try {
|
try {
|
||||||
@@ -121,18 +212,19 @@ async function remove(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyWebhook() {
|
async function copyText(text: string, msg: string) {
|
||||||
if (!webhookUrl.value) return;
|
if (!text) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(webhookUrl.value);
|
await navigator.clipboard.writeText(text);
|
||||||
ok.value = 'آدرس وبهوک کپی شد.';
|
ok.value = msg;
|
||||||
} catch {
|
} catch {
|
||||||
error.value = 'کپی ممکن نشد.';
|
error.value = 'کپی ممکن نشد.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
void load();
|
await load();
|
||||||
|
fillDaftaFromActive();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -150,79 +242,189 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</UiPageHeader>
|
</UiPageHeader>
|
||||||
|
|
||||||
|
<nav class="tabs" aria-label="نوع اتصال تلفن">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ on: tab === 'daftareshoma' }"
|
||||||
|
@click="tab = 'daftareshoma'"
|
||||||
|
>
|
||||||
|
دفتر شما
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ on: tab === 'generic' }"
|
||||||
|
@click="tab = 'generic'"
|
||||||
|
>
|
||||||
|
وبهوک عمومی
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||||
|
|
||||||
<UiCard title="اتصال جدید">
|
<template v-if="tab === 'daftareshoma'">
|
||||||
<p class="hint">
|
<UiCard title="اتصال دفتر شما">
|
||||||
سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وبهوک بفرستد.
|
<p class="hint">
|
||||||
فیلدهای اصلی:
|
توکن API را از پرتال دفتر شما (توسعهدهندگان → API) بگیرید.
|
||||||
<code>callId</code>،
|
پس از ذخیره، آدرس وبهوک زیر را در
|
||||||
<code>direction</code> (inbound/outbound)،
|
<strong>تنظیمات → هوک</strong>
|
||||||
<code>status</code>،
|
پرتال ثبت کنید و رویدادهای شروع/پایان تماس ورودی و پایان تماس خروجی را فعال کنید.
|
||||||
<code>from</code>،
|
برای تماس خروجی از CRM، توکن باید دسترسی «تماس خروجی» داشته باشد؛ برای خلاصه خودکار، «گزارش تماس» و «دریافت فایل تماس» هم لازم است.
|
||||||
<code>to</code>.
|
</p>
|
||||||
برای خلاصه خودکار یکی از
|
<div class="form-grid">
|
||||||
<code>recordingUrl</code>
|
<UiField label="نام">
|
||||||
(لینک فایل ضبط) یا
|
<UiInput v-model="daftaForm.label" placeholder="دفتر شما" />
|
||||||
<code>transcript</code>
|
</UiField>
|
||||||
(متن آماده) را بفرستید؛ پس از
|
<UiField
|
||||||
<code>completed</code>
|
:label="activeDafta?.apiTokenSet ? 'توکن API (خالی = بدون تغییر)' : 'توکن API'"
|
||||||
/
|
>
|
||||||
<code>answered</code>
|
<UiInput
|
||||||
صف خلاصه ساخته میشود.
|
v-model="daftaForm.apiToken"
|
||||||
امضا اختیاری با هدر
|
type="password"
|
||||||
<code>X-Voip-Signature</code>
|
placeholder="توکن از پرتال دفتر شما"
|
||||||
(HMAC-SHA256 هگز).
|
autocomplete="off"
|
||||||
</p>
|
/>
|
||||||
<div class="form-grid">
|
</UiField>
|
||||||
<UiField label="نام">
|
<UiField label="شماره مبدأ (from_number)">
|
||||||
<UiInput v-model="form.label" placeholder="سانترال اصلی" />
|
<UiInput
|
||||||
</UiField>
|
v-model="daftaForm.fromNumber"
|
||||||
<UiField label="رمز وبهوک (اختیاری)">
|
placeholder="مثلاً 0912…"
|
||||||
<UiInput v-model="form.webhookSecret" placeholder="برای تأیید امضا" />
|
dir="ltr"
|
||||||
</UiField>
|
/>
|
||||||
<UiField label="آدرس Click-to-Call (اختیاری)">
|
</UiField>
|
||||||
<UiInput
|
<UiField label="خط / داخلی (caller_extension)">
|
||||||
v-model="form.clickToCallUrl"
|
<UiInput
|
||||||
placeholder="https://pbx.example/originate?num={number}"
|
v-model="daftaForm.callerExtension"
|
||||||
/>
|
placeholder="مثلاً 02128…"
|
||||||
</UiField>
|
dir="ltr"
|
||||||
</div>
|
/>
|
||||||
<UiButton :loading="saving" @click="create">ذخیره اتصال</UiButton>
|
</UiField>
|
||||||
</UiCard>
|
</div>
|
||||||
|
<UiButton :loading="saving" @click="saveDafta">
|
||||||
|
{{ activeDafta ? 'بروزرسانی اتصال' : 'ذخیره اتصال دفتر شما' }}
|
||||||
|
</UiButton>
|
||||||
|
</UiCard>
|
||||||
|
|
||||||
<UiCard title="اتصالها">
|
<UiCard title="اتصالهای دفتر شما">
|
||||||
<UiEmptyState
|
<UiEmptyState
|
||||||
v-if="!loading && !items.length"
|
v-if="!loading && !daftaItems.length"
|
||||||
title="اتصالی نیست"
|
title="اتصالی نیست"
|
||||||
description="یک اتصال برای دریافت رویداد تماس بسازید."
|
description="توکن و مشخصات خط را ذخیره کنید."
|
||||||
/>
|
/>
|
||||||
<div v-else class="list">
|
<div v-else class="list">
|
||||||
<article v-for="c in items" :key="c.id" class="item">
|
<article v-for="c in daftaItems" :key="c.id" class="item">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ c.label }}</strong>
|
<strong>{{ c.label }}</strong>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
تماسهای ثبتشده {{ toFaDigits(c.callCount) }}
|
تماسهای ثبتشده {{ toFaDigits(c.callCount) }}
|
||||||
·
|
·
|
||||||
{{
|
{{
|
||||||
c.lastEventAt
|
c.lastEventAt
|
||||||
? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}`
|
? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}`
|
||||||
: 'هنوز رویدادی نیامده'
|
: 'هنوز رویدادی نیامده'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="c.clickToCallUrl" class="muted">Click-to-Call فعال</p>
|
<p class="muted">
|
||||||
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
|
مبدأ {{ c.fromNumber || '—' }} · خط {{ c.callerExtension || '—' }}
|
||||||
</div>
|
· توکن {{ c.apiTokenSet ? 'ثبت شده' : 'ندارد' }}
|
||||||
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
|
</p>
|
||||||
</article>
|
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="webhookUrl" class="webhook">
|
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
|
||||||
<p class="hint">آدرس وبهوک:</p>
|
</article>
|
||||||
<code>{{ webhookUrl }}</code>
|
</div>
|
||||||
<UiButton variant="secondary" @click="copyWebhook">کپی</UiButton>
|
<div v-if="daftaWebhookUrl" class="webhook">
|
||||||
</div>
|
<p class="hint">
|
||||||
</UiCard>
|
آدرس وبهوک برای پرتال دفتر شما (رویدادها را با JSON به این آدرس POST کنید):
|
||||||
|
</p>
|
||||||
|
<code>{{ daftaWebhookUrl }}</code>
|
||||||
|
<UiButton
|
||||||
|
variant="secondary"
|
||||||
|
@click="copyText(daftaWebhookUrl, 'آدرس وبهوک دفتر شما کپی شد.')"
|
||||||
|
>
|
||||||
|
کپی
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</UiCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<UiCard title="اتصال جدید">
|
||||||
|
<p class="hint">
|
||||||
|
سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وبهوک بفرستد.
|
||||||
|
فیلدهای اصلی:
|
||||||
|
<code>callId</code>،
|
||||||
|
<code>direction</code> (inbound/outbound)،
|
||||||
|
<code>status</code>،
|
||||||
|
<code>from</code>،
|
||||||
|
<code>to</code>.
|
||||||
|
برای خلاصه خودکار یکی از
|
||||||
|
<code>recordingUrl</code>
|
||||||
|
(لینک فایل ضبط) یا
|
||||||
|
<code>transcript</code>
|
||||||
|
(متن آماده) را بفرستید؛ پس از
|
||||||
|
<code>completed</code>
|
||||||
|
/
|
||||||
|
<code>answered</code>
|
||||||
|
صف خلاصه ساخته میشود.
|
||||||
|
امضا اختیاری با هدر
|
||||||
|
<code>X-Voip-Signature</code>
|
||||||
|
(HMAC-SHA256 هگز).
|
||||||
|
</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<UiField label="نام">
|
||||||
|
<UiInput v-model="form.label" placeholder="سانترال اصلی" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="رمز وبهوک (اختیاری)">
|
||||||
|
<UiInput v-model="form.webhookSecret" placeholder="برای تأیید امضا" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="آدرس Click-to-Call (اختیاری)">
|
||||||
|
<UiInput
|
||||||
|
v-model="form.clickToCallUrl"
|
||||||
|
placeholder="https://pbx.example/originate?num={number}"
|
||||||
|
/>
|
||||||
|
</UiField>
|
||||||
|
</div>
|
||||||
|
<UiButton :loading="saving" @click="createGeneric">ذخیره اتصال</UiButton>
|
||||||
|
</UiCard>
|
||||||
|
|
||||||
|
<UiCard title="اتصالها">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="!loading && !genericItems.length"
|
||||||
|
title="اتصالی نیست"
|
||||||
|
description="یک اتصال برای دریافت رویداد تماس بسازید."
|
||||||
|
/>
|
||||||
|
<div v-else class="list">
|
||||||
|
<article v-for="c in genericItems" :key="c.id" class="item">
|
||||||
|
<div>
|
||||||
|
<strong>{{ c.label }}</strong>
|
||||||
|
<p class="muted">
|
||||||
|
تماسهای ثبتشده {{ toFaDigits(c.callCount) }}
|
||||||
|
·
|
||||||
|
{{
|
||||||
|
c.lastEventAt
|
||||||
|
? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}`
|
||||||
|
: 'هنوز رویدادی نیامده'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p v-if="c.clickToCallUrl" class="muted">Click-to-Call فعال</p>
|
||||||
|
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
|
||||||
|
</div>
|
||||||
|
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div v-if="genericWebhookUrl" class="webhook">
|
||||||
|
<p class="hint">آدرس وبهوک:</p>
|
||||||
|
<code>{{ genericWebhookUrl }}</code>
|
||||||
|
<UiButton
|
||||||
|
variant="secondary"
|
||||||
|
@click="copyText(genericWebhookUrl, 'آدرس وبهوک کپی شد.')"
|
||||||
|
>
|
||||||
|
کپی
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</UiCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
<UiCard title="آخرین تماسها">
|
<UiCard title="آخرین تماسها">
|
||||||
<UiEmptyState
|
<UiEmptyState
|
||||||
@@ -294,6 +496,28 @@ onMounted(() => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
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,
|
.hint,
|
||||||
.muted {
|
.muted {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|||||||
@@ -5511,13 +5511,21 @@ export type VoipCallStatus =
|
|||||||
| "completed"
|
| "completed"
|
||||||
| "failed";
|
| "failed";
|
||||||
|
|
||||||
|
export type VoipProvider = "generic" | "daftareshoma";
|
||||||
|
|
||||||
export interface VoipConnectionDto {
|
export interface VoipConnectionDto {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
provider: VoipProvider;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
webhookSecretSet: boolean;
|
webhookSecretSet: boolean;
|
||||||
|
apiTokenSet: boolean;
|
||||||
/** قالب URL برای شروع تماس؛ {number} جایگزین میشود */
|
/** قالب URL برای شروع تماس؛ {number} جایگزین میشود */
|
||||||
clickToCallUrl: string | null;
|
clickToCallUrl: string | null;
|
||||||
|
/** شماره مبدأ تماس خروجی (دفتر شما) */
|
||||||
|
fromNumber: string | null;
|
||||||
|
/** خط/داخلی سازمان (دفتر شما) */
|
||||||
|
callerExtension: string | null;
|
||||||
lastEventAt: string | null;
|
lastEventAt: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
callCount: number;
|
callCount: number;
|
||||||
@@ -5527,8 +5535,13 @@ export interface VoipConnectionDto {
|
|||||||
|
|
||||||
export interface CreateVoipConnectionRequest {
|
export interface CreateVoipConnectionRequest {
|
||||||
label: string;
|
label: string;
|
||||||
|
provider?: VoipProvider;
|
||||||
webhookSecret?: string;
|
webhookSecret?: string;
|
||||||
clickToCallUrl?: string | null;
|
clickToCallUrl?: string | null;
|
||||||
|
/** توکن Bearer API دفتر شما */
|
||||||
|
apiToken?: string;
|
||||||
|
fromNumber?: string | null;
|
||||||
|
callerExtension?: string | null;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5536,6 +5549,9 @@ export interface UpdateVoipConnectionRequest {
|
|||||||
label?: string;
|
label?: string;
|
||||||
webhookSecret?: string | null;
|
webhookSecret?: string | null;
|
||||||
clickToCallUrl?: string | null;
|
clickToCallUrl?: string | null;
|
||||||
|
apiToken?: string | null;
|
||||||
|
fromNumber?: string | null;
|
||||||
|
callerExtension?: string | null;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user