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>
218 lines
12 KiB
JavaScript
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 }
|