gigafibre-fsm/services/targo-hub/lib/chatwoot.js
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

218 lines
12 KiB
JavaScript

// Pont Chatwoot ↔ TARGO (émulation des features « entreprise » par NOTRE stack).
// GET /chatwoot/panel → fiche client ERPNext 360 (Dashboard App iframe)
// GET /chatwoot/lookup?email&phone&key → JSON fiche (clé CHATWOOT_PANEL_KEY ; PII)
// POST /chatwoot/bot?key → webhook Agent Bot : (1) miroir ERPNext (timeline),
// (2) « Captain » émulé = Gemini rédige une réponse →
// NOTE PRIVÉE (l'IA propose, l'humain dispose).
// POST /chatwoot/sla-scan?key → déclenche un scan SLA (aussi en cron, opt-in).
const https = require('https')
const fs = require('fs')
const nodePath = require('path')
const { json, cors, log, parseBody, lookupCustomersByEmail, lookupCustomersByPhone, createCommunication } = require('./helpers')
const payments = require('./payments')
const KEY = process.env.CHATWOOT_PANEL_KEY || ''
const BOT_KEY = process.env.CHATWOOT_BOT_KEY || ''
const CW_BASE = process.env.CHATWOOT_BASE_URL || 'https://chatwoot.gigafibre.ca'
const CW_ACCOUNT = process.env.CHATWOOT_ACCOUNT_ID || '1'
const CW_BOT_TOKEN = process.env.CHATWOOT_BOT_TOKEN || ''
const CW_AGENT_TOKEN = process.env.CHATWOOT_AGENT_TOKEN || ''
const OPS_BASE = process.env.OPS_BASE_URL || 'https://erp.gigafibre.ca/ops'
// Cibles SLA de PREMIÈRE RÉPONSE (minutes) par priorité Chatwoot — alignées sur lib/sla DEFAULTS.
let SLA_RESPONSE = { urgent: 30, high: 60, medium: 240, low: 480, none: 480 }
try { if (process.env.CHATWOOT_SLA_RESPONSE) SLA_RESPONSE = Object.assign(SLA_RESPONSE, JSON.parse(process.env.CHATWOOT_SLA_RESPONSE)) } catch (e) { /* défauts */ }
let PANEL_HTML = null
function panelHtml () {
if (PANEL_HTML == null) {
try { PANEL_HTML = fs.readFileSync(nodePath.join(__dirname, 'chatwoot-panel.html'), 'utf8') } catch (e) { PANEL_HTML = '<!doctype html><meta charset="utf-8"><p>Panel indisponible</p>' }
}
return PANEL_HTML
}
// Résout une fiche ERPNext depuis courriel (prioritaire) ou téléphone, + solde/factures.
async function lookup (email, phone) {
let matches = []
if (email) matches = await lookupCustomersByEmail(email, 6)
if (!matches.length && phone) matches = await lookupCustomersByPhone(phone, 6)
if (!matches.length) return { found: false, candidates: [], query: { email, phone } }
const top = matches[0]
let balance = 0
let invoices = []
try {
const r = await payments.getCustomerBalance(top.name)
balance = r.balance
invoices = (r.invoices || []).slice(0, 8)
} catch (e) { /* solde best-effort */ }
const opsUrl = n => OPS_BASE + '/#/clients/' + encodeURIComponent(n)
return {
found: true,
customer: { name: top.name, customer_name: top.customer_name || top.name, territory: top.territory || '', email: top.email || email || '', phone: top.phone || phone || '' },
balance,
invoices,
candidates: matches.slice(1).map(m => ({ name: m.name, customer_name: m.customer_name || m.name, opsUrl: opsUrl(m.name) })),
opsUrl: opsUrl(top.name)
}
}
// Appel API Chatwoot avec un jeton donné (bot pour le copilote ; agent pour le scan SLA).
function cwReq (method, apiPath, bodyObj, token) {
return new Promise((resolve, reject) => {
const u = new URL(CW_BASE + apiPath)
const data = bodyObj ? JSON.stringify(bodyObj) : null
const headers = { 'Content-Type': 'application/json', api_access_token: token || '' }
if (data) headers['Content-Length'] = Buffer.byteLength(data)
const req = https.request({ hostname: u.hostname, port: 443, path: u.pathname + u.search, method, headers }, r => {
let b = ''
r.on('data', c => { b += c })
r.on('end', () => resolve({ status: r.statusCode, body: b }))
})
req.on('error', reject)
req.setTimeout(15000, () => req.destroy(new Error('chatwoot api timeout')))
if (data) req.write(data)
req.end()
})
}
const chatwootApi = (m, p, b) => cwReq(m, p, b, CW_BOT_TOKEN)
const cwAgentApi = (m, p, b) => cwReq(m, p, b, CW_AGENT_TOKEN)
const isIncoming = m => m === 'incoming' || m === 0
// Miroir d'un message Chatwoot → ERPNext (Communication liée au Customer) = timeline fiche OPS.
async function mirrorToErp (ev, fiche, conv) {
if (!fiche || !fiche.found) { log('chatwoot mirror: pas de fiche liée'); return }
const incoming = isIncoming(ev.message_type)
const convId = conv.id || conv.display_id || ev.conversation_id
const convUrl = `${CW_BASE}/app/accounts/${CW_ACCOUNT}/conversations/${convId}`
const r = await createCommunication({
communication_type: 'Communication',
communication_medium: 'Chat',
subject: 'Chatwoot — ' + (fiche.customer.customer_name || ''),
content: (incoming ? 'Client : ' : 'Agent : ') + String(ev.content || '').slice(0, 4000) + '\n\n[Chatwoot] ' + convUrl,
sent_or_received: incoming ? 'Received' : 'Sent',
reference_doctype: 'Customer',
reference_name: fiche.customer.name,
status: 'Linked'
})
log('chatwoot mirror: Communication HTTP ' + (r && r.status) + ' → ' + fiche.customer.name)
}
// Traite un évènement Agent Bot : (1) miroir ERPNext, (2) suggestion IA en note privée (message client).
async function processBotEvent (ev) {
if (!ev || ev.event !== 'message_created') return
if (ev.private) return // jamais les notes internes (ni copilote, ni mirror → pas de boucle)
const conv = ev.conversation || {}
const convId = conv.id || ev.conversation_id
if (!convId) return
const sender = (conv.meta && conv.meta.sender) || ev.sender || {}
let fiche = null
try { fiche = await lookup(sender.email || '', sender.phone_number || '') } catch (e) { /* best-effort */ }
// (1) Miroir timeline ERPNext — entrant + sortant public
mirrorToErp(ev, fiche, conv).catch(e => log('chatwoot mirror: ' + (e && e.message)))
// (2) Copilote IA — uniquement sur un message CLIENT (entrant)
if (!isIncoming(ev.message_type)) return
const ficheTxt = (fiche && fiche.found)
? `Fiche client : ${fiche.customer.customer_name} · ${fiche.customer.territory || '—'} · solde ${fiche.balance} $ · ${fiche.invoices.length} facture(s) impayée(s).`
: 'Aucune fiche client liée (nouveau contact).'
const recent = (conv.messages || []).slice(-8)
.map(m => (isIncoming(m.message_type) ? 'Client : ' : 'Agent : ') + String(m.content || '').slice(0, 500))
.join('\n')
const lastText = ev.content || ''
const sys = "Tu es un agent du support technique d'un fournisseur Internet résidentiel (TARGO / Gigafibre). " +
"Rédige une RÉPONSE COURTE, polie et CONCRÈTE en français, à proposer à l'agent humain qui la validera avant envoi. " +
'Utilise le contexte client si pertinent (solde, factures). Va droit au but, pas de longues salutations. ' +
"Ne promets rien d'irréversible (remboursement, crédit, rendez-vous ferme) — propose plutôt de vérifier."
const userMsg = ficheTxt + '\n\nFil récent :\n' + (recent || ('Client : ' + lastText)) + '\n\nDernier message du client : ' + lastText + '\n\nRéponse suggérée :'
let reply = ''
try {
reply = await require('./ai').chat({ task: 'copilot', maxTokens: 400, temperature: 0.3, messages: [{ role: 'system', content: sys }, { role: 'user', content: userMsg }] })
} catch (e) { log('chatwoot copilot AI: ' + e.message); return }
if (!reply || !reply.trim()) return
const note = '💡 Réponse suggérée (IA) :\n\n' + reply.trim() + "\n\n— Copilote TARGO · à vérifier avant d'envoyer"
try {
const r = await chatwootApi('POST', `/api/v1/accounts/${CW_ACCOUNT}/conversations/${convId}/messages`, { content: note, private: true })
log(`chatwoot copilot: note conv=${convId} status=${r.status}`)
} catch (e) { log('chatwoot copilot post: ' + e.message) }
}
async function handleBot (req, res, url) {
const key = url.searchParams.get('key') || ''
if (!BOT_KEY || key !== BOT_KEY) return json(res, 401, { error: 'unauthorized' })
let ev = {}
try { ev = await parseBody(req) } catch (e) { return json(res, 400, { error: 'bad body' }) }
json(res, 200, { ok: true }) // ACK rapide ; traitement en arrière-plan
processBotEvent(ev).catch(e => log('chatwoot bot: ' + (e && e.message)))
}
// Émulation EE « SLA » : convs ouvertes SANS 1re réponse au-delà de la cible (par priorité)
// → étiquette « sla-en-retard » + note privée. Idempotent (skip si déjà étiqueté). Jeton AGENT.
async function slaScan () {
if (!CW_AGENT_TOKEN) return { scanned: 0, flagged: 0, skipped: 'no_agent_token' }
const r = await cwAgentApi('GET', `/api/v1/accounts/${CW_ACCOUNT}/conversations?status=open`)
if (r.status !== 200) { log('chatwoot sla: list HTTP ' + r.status); return { scanned: 0, flagged: 0, error: r.status } }
let payload = []
try { payload = JSON.parse(r.body).data.payload || [] } catch (e) { return { scanned: 0, flagged: 0, error: 'parse' } }
const nowS = Math.floor(Date.now() / 1000)
let flagged = 0
for (const c of payload) {
if (c.first_reply_created_at) continue // déjà répondu → SLA réponse respecté
const since = c.waiting_since || c.created_at || nowS
const mins = Math.round((nowS - since) / 60)
const prio = c.priority || 'none'
const target = SLA_RESPONSE[prio] != null ? SLA_RESPONSE[prio] : SLA_RESPONSE.none
if (mins < target) continue
let cur = []
try { const lr = await cwAgentApi('GET', `/api/v1/accounts/${CW_ACCOUNT}/conversations/${c.id}/labels`); cur = JSON.parse(lr.body).payload || [] } catch (e) { /* */ }
if (cur.includes('sla-en-retard')) continue // déjà signalé
await cwAgentApi('POST', `/api/v1/accounts/${CW_ACCOUNT}/conversations/${c.id}/labels`, { labels: cur.concat(['sla-en-retard']) })
await cwAgentApi('POST', `/api/v1/accounts/${CW_ACCOUNT}/conversations/${c.id}/messages`, { content: `⏰ SLA réponse dépassé — ${mins} min sans réponse (cible ${target} min, priorité ${prio}). À prendre en charge en priorité.`, private: true })
flagged++
}
if (flagged) log('chatwoot sla: ' + flagged + ' conv(s) en retard signalée(s)')
return { scanned: payload.length, flagged }
}
function startSlaScan () {
if (process.env.CHATWOOT_SLA_SCAN !== 'on') return
log('chatwoot sla: scan activé (toutes les 5 min)')
setTimeout(() => slaScan().catch(e => log('chatwoot sla: ' + (e && e.message))), 20000)
setInterval(() => slaScan().catch(e => log('chatwoot sla: ' + (e && e.message))), 5 * 60 * 1000)
}
async function handle (req, res, method, path, url) {
cors(res, 'GET, POST, OPTIONS')
if (method === 'OPTIONS') { res.writeHead(204); return res.end() }
if (path === '/chatwoot/panel') {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache',
'Content-Security-Policy': 'frame-ancestors https://chatwoot.gigafibre.ca'
})
return res.end(panelHtml())
}
if (path === '/chatwoot/lookup') {
const key = url.searchParams.get('key') || ''
if (!KEY || key !== KEY) return json(res, 401, { error: 'unauthorized' })
const email = (url.searchParams.get('email') || '').trim()
const phone = (url.searchParams.get('phone') || '').trim()
if (!email && !phone) return json(res, 200, { found: false, candidates: [], query: {} })
try { return json(res, 200, await lookup(email, phone)) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (path === '/chatwoot/bot' && method === 'POST') return handleBot(req, res, url)
if (path === '/chatwoot/sla-scan' && method === 'POST') {
if (!BOT_KEY || (url.searchParams.get('key') || '') !== BOT_KEY) return json(res, 401, { error: 'unauthorized' })
try { return json(res, 200, await slaScan()) } catch (e) { return json(res, 500, { error: e.message }) }
}
return json(res, 404, { error: 'not found' })
}
module.exports = { handle, startSlaScan }