'use strict' const cfg = require('./config') const { log, json, parseBody, httpRequest, lookupCustomerByPhone, createCommunication } = require('./helpers') const { broadcast, broadcastAll } = require('./sse') let pbxToken = null, pbxTokenExpiry = 0 const processedCallIds = new Set() async function get3cxToken () { if (pbxToken && Date.now() < pbxTokenExpiry - 60000) return pbxToken if (!cfg.PBX_USER || !cfg.PBX_PASS) return null try { const res = await httpRequest(cfg.PBX_URL, '/webclient/api/Login/GetAccessToken', { method: 'POST', body: { Username: cfg.PBX_USER, Password: cfg.PBX_PASS }, }) if (res.data.Status === 'AuthSuccess') { pbxToken = res.data.Token.access_token pbxTokenExpiry = Date.now() + (res.data.Token.expires_in * 1000) return pbxToken } log('3CX auth failed:', res.data.Status) } catch (e) { log('3CX auth error:', e.message) } return null } function formatDuration (sec) { return `${Math.floor(sec / 60)}m${(sec % 60).toString().padStart(2, '0')}s` } function parseIsoDuration (td) { if (typeof td === 'number') return td if (typeof td !== 'string' || !td.startsWith('PT')) return 0 const hr = td.match(/(\d+)H/), mn = td.match(/(\d+)M/), sc = td.match(/(\d+)S/) return (hr ? parseInt(hr[1]) * 3600 : 0) + (mn ? parseInt(mn[1]) * 60 : 0) + (sc ? parseInt(sc[1]) : 0) } async function logCallToErp (remotePhone, isOutbound, duration, customerLabel, customerName, displayName) { if (!customerName || duration <= 0) return await createCommunication({ communication_type: 'Communication', communication_medium: 'Phone', sent_or_received: isOutbound ? 'Sent' : 'Received', sender: 'sms@gigafibre.ca', sender_full_name: isOutbound ? (displayName || 'Targo Ops') : customerLabel, phone_no: remotePhone, content: `Appel ${isOutbound ? 'sortant vers' : 'entrant de'} ${remotePhone} — Duree: ${formatDuration(duration)}`, subject: `Appel ${isOutbound ? 'vers' : 'de'} ${remotePhone}`, reference_doctype: 'Customer', reference_name: customerName, status: 'Linked', }) } async function callEvent (req, res) { const body = await parseBody(req) const eventType = body.event_type || body.EventType || body.type || '' const callId = body.call_id || body.CallId || body.id || '' const direction = (body.direction || body.Direction || '').toLowerCase() const caller = body.caller || body.Caller || body.from || '' const callee = body.callee || body.Callee || body.to || '' const ext = body.ext || body.Extension || body.extension || '' const duration = parseInt(body.duration || body.Duration || '0', 10) const status = body.status || body.Status || eventType const isOutbound = direction === 'outbound' || direction === 'out' const remotePhone = isOutbound ? callee : caller log(`3CX ${eventType}: ${caller} → ${callee} ext=${ext} dir=${direction} dur=${duration}s status=${status} (${callId})`) json(res, 200, { ok: true }) setImmediate(async () => { try { const isCallEnd = ['ended', 'completed', 'hangup', 'Notified', 'missed'].some( s => eventType.toLowerCase().includes(s.toLowerCase()) || status.toLowerCase().includes(s.toLowerCase()) ) const customer = remotePhone ? await lookupCustomerByPhone(remotePhone) : null const customerName = customer?.name, customerLabel = customer?.customer_name || 'Inconnu' if (isCallEnd) await logCallToErp(remotePhone, isOutbound, duration, customerLabel, customerName) const eventData = { type: 'call', event: eventType, direction: isOutbound ? 'out' : 'in', customer: customerName, customer_name: customerLabel, phone: remotePhone, extension: ext, duration, status, call_id: callId, ts: new Date().toISOString(), } if (customerName) broadcast('customer:' + customerName, 'call-event', eventData) broadcastAll('call-event', eventData) log(`3CX logged: ${remotePhone} → ${customerName || 'UNKNOWN'} (${callId})`) } catch (e) { log('3CX call processing error:', e.message) } }) } async function poll3cxCallLog () { const token = await get3cxToken() if (!token) return try { const since = new Date(Date.now() - 5 * 60 * 1000).toISOString() const url = `${cfg.PBX_URL}/xapi/v1/ReportCallLogData?$top=20&$orderby=StartTime%20desc&$filter=StartTime%20gt%20${encodeURIComponent(since)}` const res = await httpRequest(cfg.PBX_URL, url.replace(cfg.PBX_URL, ''), { headers: { Authorization: 'Bearer ' + token }, }) if (res.status !== 200 || !res.data?.value) return for (const call of res.data.value) { const callId = call.CdrId || call.CallHistoryId || '' if (!callId || processedCallIds.has(callId)) continue if (!call.TalkingDuration && !call.Answered) continue processedCallIds.add(callId) const direction = (call.Direction || '').toLowerCase().includes('inbound') ? 'in' : 'out' const isOutbound = direction === 'out' const remotePhone = isOutbound ? (call.DestinationCallerId || call.DestinationDn || '') : (call.SourceCallerId || call.SourceDn || '') const ext = isOutbound ? call.SourceDn : call.DestinationDn const durationSec = parseIsoDuration(call.TalkingDuration || '') if (durationSec < 3 && !call.Answered) continue log(`3CX POLL: ${call.SourceCallerId || call.SourceDn} → ${call.DestinationCallerId || call.DestinationDn} dir=${direction} dur=${durationSec}s (${callId})`) try { const customer = remotePhone ? await lookupCustomerByPhone(remotePhone) : null const customerName = customer?.name, customerLabel = customer?.customer_name || 'Inconnu' if (customerName) { const suffix = call.Answered ? '' : ' (manque)' await createCommunication({ communication_type: 'Communication', communication_medium: 'Phone', sent_or_received: isOutbound ? 'Sent' : 'Received', sender: 'sms@gigafibre.ca', sender_full_name: isOutbound ? (call.SourceDisplayName || 'Targo Ops') : customerLabel, phone_no: remotePhone, content: `Appel ${isOutbound ? 'sortant vers' : 'entrant de'} ${remotePhone} — Duree: ${formatDuration(durationSec)}${suffix}`, subject: `Appel ${isOutbound ? 'vers' : 'de'} ${remotePhone}`, reference_doctype: 'Customer', reference_name: customerName, status: 'Linked', }) log(`3CX logged to ERPNext: ${remotePhone} → ${customerName} (${formatDuration(durationSec)})`) } const eventData = { type: 'call', event: 'completed', direction, customer: customerName, customer_name: customerLabel, phone: remotePhone, extension: ext, duration: durationSec, answered: call.Answered, call_id: callId, ts: call.StartTime || new Date().toISOString(), } if (customerName) broadcast('customer:' + customerName, 'call-event', eventData) broadcastAll('call-event', eventData) } catch (e) { log('3CX poll processing error:', e.message) } } // Prune old IDs (keep last 500) if (processedCallIds.size > 500) { const arr = [...processedCallIds].slice(-500) processedCallIds.clear() arr.forEach(id => processedCallIds.add(id)) } } catch (e) { log('3CX poll error:', e.message) } } function startPoller () { if (!cfg.PBX_ENABLED) { log('3CX poller: DISABLED (PBX_ENABLED != 1)'); return } if (!cfg.PBX_USER || !cfg.PBX_PASS) { log('3CX poller: DISABLED (no credentials)'); return } log(`3CX poller: ENABLED (every ${cfg.PBX_POLL_INTERVAL / 1000}s) → ${cfg.PBX_URL}`) setTimeout(poll3cxCallLog, 5000) setInterval(poll3cxCallLog, cfg.PBX_POLL_INTERVAL) } module.exports = { callEvent, startPoller }