Drop required from_number for webhook logging, persist CalleeExtension as lineExtension, and add direction/status/line filters on the calls list. Co-authored-by: Cursor <cursoragent@cursor.com>
169 lines
4.9 KiB
TypeScript
169 lines
4.9 KiB
TypeScript
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 lineExtension =
|
|
pick(obj, 'CalleeExtension', 'calleeExtension', 'Extension', 'extension') ||
|
|
undefined;
|
|
/** مقصد انتقال / شماره طرف مقابل روی خط */
|
|
const transferred = pick(
|
|
obj,
|
|
'TransferredToNumber',
|
|
'transferredToNumber',
|
|
);
|
|
|
|
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',
|
|
);
|
|
|
|
// inbound: from=مشتری، to=همکار/مقصد انتقال یا خود خط
|
|
// outbound: from=همکار، to=مشتری (TransferredToNumber)
|
|
let from = caller || undefined;
|
|
let to: string | undefined;
|
|
if (isOutbound) {
|
|
to = transferred || undefined;
|
|
} else {
|
|
to = transferred || lineExtension || undefined;
|
|
}
|
|
|
|
const event: VoipWebhookEvent = {
|
|
callId,
|
|
direction: isOutbound ? 'outbound' : 'inbound',
|
|
status,
|
|
from,
|
|
to,
|
|
lineExtension,
|
|
startedAt,
|
|
endedAt,
|
|
durationSec,
|
|
caller: from,
|
|
callee: to,
|
|
};
|
|
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')),
|
|
);
|
|
}
|