- 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>
211 lines
9.1 KiB
JavaScript
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 }
|