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:
2026-11-07 12:31:59 +03:30
co-authored by Cursor
parent 21d2c69227
commit abb1a3a18e
13 changed files with 934 additions and 90 deletions
@@ -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");
+13 -2
View File
@@ -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 },
+138 -1
View File
@@ -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')),
);
}
+35
View File
@@ -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,
], ],
+304 -80
View File
@@ -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;
+16
View File
@@ -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;
} }