'use strict' const cfg = require('./config') const { log, json, parseBody, lookupCustomerByPhone, createCommunication } = require('./helpers') const { broadcast, broadcastAll } = require('./sse') async function smsIncoming (req, res) { const body = await parseBody(req) const from = body.From || '', to = body.To || '', text = body.Body || '', sid = body.MessageSid || '' log(`SMS IN: ${from} → ${to}: ${text.substring(0, 50)}...`) res.writeHead(200, { 'Content-Type': 'text/xml' }) res.end('') setImmediate(async () => { try { // Intercept tech absence commands (absent, absent demain, retour, etc.) const { handleAbsenceSms } = require('./tech-absence-sms') const wasAbsenceCmd = await handleAbsenceSms(from, text) if (wasAbsenceCmd) { log(`SMS handled as tech absence command: ${from}`); return } const customer = await lookupCustomerByPhone(from) let customerName = customer?.name, customerLabel = customer?.customer_name || 'Inconnu' const { findConversationByPhone, addMessage, autoLinkIncomingSms, triggerAgent } = require('./conversation') const existingConv = findConversationByPhone(from) if (existingConv) { addMessage(existingConv, { from: 'customer', text, via: 'sms' }) log(`SMS routed to conversation ${existingConv.token}`) // Fall back to conversation's customer if ERP lookup failed if (!customerName && existingConv.customer) { customerName = existingConv.customer customerLabel = existingConv.customerName || 'Inconnu' } if (existingConv.customer) triggerAgent(existingConv) } else { // autoLinkIncomingSms already sends a welcome SMS — don't trigger agent on first contact // to avoid sending a duplicate AI reply immediately after the greeting await autoLinkIncomingSms(from, text) } if (customerName) { await createCommunication({ communication_type: 'Communication', communication_medium: 'SMS', sent_or_received: 'Received', sender: from, sender_full_name: customerLabel, phone_no: from, content: text, subject: 'SMS from ' + from, reference_doctype: 'Customer', reference_name: customerName, message_id: sid, status: 'Open', }) } const eventData = { type: 'sms', direction: 'in', customer: customerName, customer_name: customerLabel, phone: from, text, sid, ts: new Date().toISOString(), } let n = 0 if (customerName) n = broadcast('customer:' + customerName, 'message', eventData) broadcastAll('sms-incoming', eventData) log(`SMS logged: ${from} → ${customerName || 'UNKNOWN'} (${sid}) broadcast=${n}`) } catch (e) { log('SMS processing error:', e.message) } }) } async function smsStatus (req, res) { const body = await parseBody(req) const sid = body.MessageSid || body.SmsSid || '' const status = body.MessageStatus || body.SmsStatus || '' log(`SMS STATUS: ${sid} → ${status}`) res.writeHead(200, { 'Content-Type': 'text/xml' }) res.end('') setImmediate(() => broadcastAll('sms-status', { sid, status, ts: new Date().toISOString() })) } async function sendSmsInternal (phone, message, customer) { // Normalize to E.164: strip non-digits, add +1 for 10-digit North American numbers const digits = phone.replace(/\D/g, '') const to = phone.startsWith('+') ? phone : digits.length === 10 ? '+1' + digits : digits.length === 11 && digits.startsWith('1') ? '+' + digits : '+' + digits const twilioData = new URLSearchParams({ To: to, From: cfg.TWILIO_FROM, Body: message, StatusCallback: cfg.HUB_PUBLIC_URL + '/webhook/twilio/sms-status', }) const authStr = Buffer.from(cfg.TWILIO_ACCOUNT_SID + ':' + cfg.TWILIO_AUTH_TOKEN).toString('base64') const https = require('https') const twilioRes = await new Promise((resolve, reject) => { const r = https.request({ hostname: 'api.twilio.com', path: `/2010-04-01/Accounts/${cfg.TWILIO_ACCOUNT_SID}/Messages.json`, method: 'POST', headers: { Authorization: 'Basic ' + authStr, 'Content-Type': 'application/x-www-form-urlencoded' }, }, (resp) => { let b = ''; resp.on('data', c => b += c) resp.on('end', () => { try { resolve({ status: resp.statusCode, data: JSON.parse(b) }) } catch { resolve({ status: resp.statusCode, data: b }) } }) }) r.on('error', reject); r.write(twilioData.toString()); r.end() }) if (twilioRes.status >= 400) { log('Twilio send error:', twilioRes.data); return null } const sid = twilioRes.data.sid || '' log(`SMS OUT: → ${phone}: ${message.substring(0, 50)}... (${sid})`) return sid } async function sendSms (req, res) { const body = await parseBody(req) const { phone, message, customer } = body if (!phone || !message) return json(res, 400, { error: 'Missing phone or message' }) const sid = await sendSmsInternal(phone, message, customer) if (!sid) return json(res, 502, { ok: false, error: 'Twilio error' }) if (customer) { await createCommunication({ communication_type: 'Communication', communication_medium: 'SMS', sent_or_received: 'Sent', sender: 'sms@gigafibre.ca', sender_full_name: 'Targo Ops', phone_no: phone, content: message, subject: 'SMS to ' + phone, reference_doctype: 'Customer', reference_name: customer, message_id: sid, status: 'Linked', }) broadcast('customer:' + customer, 'message', { type: 'sms', direction: 'out', customer, phone, text: message, sid, ts: new Date().toISOString(), }) } return json(res, 200, { ok: true, sid }) } function voiceToken (req, res, url) { if (!cfg.TWILIO_API_KEY || !cfg.TWILIO_API_SECRET || !cfg.TWILIO_TWIML_APP_SID) { return json(res, 503, { error: 'Twilio Voice not configured' }) } const identity = url.searchParams.get('identity') || req.headers['x-authentik-email'] || 'ops-agent' try { const twilio = require('twilio') const token = new twilio.jwt.AccessToken( cfg.TWILIO_ACCOUNT_SID, cfg.TWILIO_API_KEY, cfg.TWILIO_API_SECRET, { identity, ttl: 3600 } ) token.addGrant(new twilio.jwt.AccessToken.VoiceGrant({ outgoingApplicationSid: cfg.TWILIO_TWIML_APP_SID, incomingAllow: true, })) log(`Voice token generated for ${identity}`) return json(res, 200, { token: token.toJwt(), identity }) } catch (e) { log('Voice token error:', e.message) return json(res, 500, { error: 'Token generation failed: ' + e.message }) } } function sipConfig (req, res) { return json(res, 200, { wssUrl: cfg.SIP_WSS_URL, domain: cfg.SIP_DOMAIN, extension: cfg.SIP_EXTENSION, authId: cfg.SIP_AUTH_ID, authPassword: cfg.SIP_AUTH_PASSWORD, displayName: cfg.SIP_DISPLAY_NAME, identity: cfg.SIP_EXTENSION, }) } async function voiceTwiml (req, res) { const body = await parseBody(req) const to = body.To || body.phone || '' log(`Voice TwiML: dialing ${to}`) const callerId = cfg.TWILIO_FROM || '+14382313838' const twiml = ` ${to} ` res.writeHead(200, { 'Content-Type': 'text/xml' }) res.end(twiml) } async function voiceStatus (req, res) { const body = await parseBody(req) const { CallSid: callSid = '', CallStatus: callStatus = '', To: to = '', From: from = '' } = body const duration = parseInt(body.CallDuration || '0', 10) log(`Voice STATUS: ${callSid} ${from}→${to} status=${callStatus} dur=${duration}s`) res.writeHead(200, { 'Content-Type': 'text/xml' }) res.end('') if (callStatus === 'completed' && duration > 0) { setImmediate(async () => { try { const phone = to.startsWith('client:') ? from : to const customer = await lookupCustomerByPhone(phone) const customerName = customer?.name if (customerName) { const dMin = Math.floor(duration / 60), dSec = duration % 60 const durStr = `${dMin}m${dSec.toString().padStart(2, '0')}s` await createCommunication({ communication_type: 'Communication', communication_medium: 'Phone', sent_or_received: 'Sent', sender: 'sms@gigafibre.ca', sender_full_name: 'Targo Ops', phone_no: phone, content: `Appel vers ${phone} — Duree: ${durStr}`, subject: `Appel vers ${phone}`, reference_doctype: 'Customer', reference_name: customerName, status: 'Linked', }) broadcast('customer:' + customerName, 'call-event', { type: 'call', event: 'completed', direction: 'out', customer: customerName, phone, duration, call_id: callSid, ts: new Date().toISOString(), }) log(`Voice logged: ${phone} → ${customerName} (${durStr})`) } } catch (e) { log('Voice status processing error:', e.message) } }) } } module.exports = { smsIncoming, smsStatus, sendSms, sendSmsInternal, voiceToken, sipConfig, voiceTwiml, voiceStatus }