gigafibre-fsm/services/targo-hub/lib/twilio.js
louispaulb 320655b0a0 refactor: major cleanup — remove dead dispatch app, commit all backend code, extract client composables
- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained)
- Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked)
- Commit services/docuseal + services/legacy-db docker-compose configs
- Extract client app composables: useOTP, useAddressSearch, catalog data, format utils
- Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines
- Clean hardcoded credentials from config.js fallback values
- Add client portal: catalog, cart, checkout, OTP verification, address search
- Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal
- Add ops composables: useBestTech, useConversations, usePermissions, useScanner
- Add field app: scanner composable, docker/nginx configs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:38:38 -04:00

211 lines
9.1 KiB
JavaScript

'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('<Response></Response>')
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('<Response></Response>')
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 = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Dial callerId="${callerId}" answerOnBridge="true" timeout="30">
<Number statusCallbackEvent="initiated ringing answered completed"
statusCallback="${cfg.HUB_PUBLIC_URL}/voice/status"
statusCallbackMethod="POST">${to}</Number>
</Dial>
</Response>`
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('<Response></Response>')
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 }