// 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 = '
Panel indisponible
' } } 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 }