gigafibre-fsm/services/targo-hub/lib/conversation.js
louispaulb 1b7bad1686 security: harden payments/hub auth + remove leaked ERPNext token from source
Auth hardening:
- payments: per-customer JWT authorization on dual-use /payments routes
  (balance/methods/invoice/checkout/setup/portal/toggle-ppa) via authorizeCustomer
  + PAYMENTS_AUTH=enforce; portal retains+sends magic-link JWT (sessionStorage)
- hub: fail-closed Stripe webhook, /accept/doc-pdf IDOR gate, telephony field-name
  guard (SQLi), modem-bridge private-IP guard (SSRF), ALWAYS_ENFORCE expansion

Leaked-credential cleanup (token already rotated in ERPNext):
- de-hardcode ERPNext API token -> env in bulk_submit.py, import_items.py,
  apps/client/deploy.sh; placeholder in apps/ops/infra/nginx.conf (nginx injects)
- ops prod build no longer bakes VITE_ERP_TOKEN (.env.production empty)
- de-hardcode legacy DB password -> env in import_items.py
- gitignore legacy migration PII exports (tsv/json)

Conversations/UI:
- floating (un-docked) conversation panel; full-width mailbox
- per-message real sender from email headers; unified scroll; header spacing
- campaign pre-send review (subject/from/channel), Gmail send channel, clone-as-draft

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:17:17 -04:00

1457 lines
98 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { log, json, parseBody, lookupCustomerByPhone, lookupCustomersByPhone, lookupCustomersByEmail, createCommunication, readJsonFile, writeJsonFile, stripDataUrl, OWN_DOMAINS } = require('./helpers')
const erp = require('./erp')
const sse = require('./sse')
const DATA_DIR = path.join(__dirname, '..', 'data')
const CONV_FILE = path.join(DATA_DIR, 'conversations.json')
const conversations = new Map()
const pushSubscriptions = new Map()
const agentTimers = new Map()
function loadFromDisk () {
try {
if (!fs.existsSync(CONV_FILE)) return
for (const conv of JSON.parse(fs.readFileSync(CONV_FILE, 'utf8'))) conversations.set(conv.token, conv)
log(`Loaded ${conversations.size} conversations from disk`)
} catch (e) { log('Failed to load conversations:', e.message) }
}
let saveTimer = null
function saveToDisk () {
if (saveTimer) return
saveTimer = setTimeout(() => {
saveTimer = null
try {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
fs.writeFileSync(CONV_FILE + '.tmp', JSON.stringify([...conversations.values()], null, 0))
fs.renameSync(CONV_FILE + '.tmp', CONV_FILE)
} catch (e) { log('Failed to save conversations:', e.message) }
}, 500)
}
loadFromDisk()
function generateToken () { return crypto.randomBytes(4).toString('base64url') }
function createConversation ({ phone, customer, customerName, agentEmail, subject }) {
const token = generateToken()
const now = new Date().toISOString()
const conv = {
token, phone, customer: customer || null, customerName: customerName || 'Client',
agentEmail: agentEmail || null, subject: subject || 'Conversation',
status: 'active', messages: [], createdAt: now, lastActivity: now,
expiresAt: new Date(Date.now() + 30 * 24 * 3600000).toISOString(), // 30 j (lien web-chat persistant ; le client peut toujours répondre par SMS)
smsCount: 0, pushSub: null,
}
conversations.set(token, conv)
saveToDisk()
log(`Conversation created: ${token} for ${phone} (${customerName})`)
return conv
}
function getConversation (token) {
const conv = conversations.get(token)
if (!conv) return null
if (new Date(conv.expiresAt) < new Date()) { conv.status = 'expired'; return null }
return conv
}
function addMessage (conv, { from, text, type = 'text', via = 'web', media = '', html = '', agent = '', fromName = '', fromEmail = '', gmail_id = '' }) {
const msg = { id: crypto.randomUUID(), from, text, type, via, media, ts: new Date().toISOString() }
if (html) msg.html = html // courriel : HTML complet (rendu WYSIWYG iframe côté Ops)
if (from === 'agent' && agent) msg.agent = agent // identité de l'agent qui répond → stats « réponses par employé » (classement Inbox)
if (fromName) msg.fromName = fromName // expéditeur RÉEL (en-tête From) PAR message → fil multi-expéditeurs affiche le bon nom
if (fromEmail) msg.fromEmail = fromEmail
if (gmail_id) msg.gmail_id = gmail_id // mapping message↔Gmail (backfill / robustesse)
conv.messages.push(msg)
if (!conv.initiatedBy) conv.initiatedBy = from // qui a OUVERT le fil : 'agent' = nous (notif/relance → l'IA n'auto-répond pas) ; 'customer' = demande entrante
conv.lastActivity = msg.ts
saveToDisk()
sse.broadcast('conv:' + conv.token, 'conv-message', { token: conv.token, message: msg })
sse.broadcast('conv-client:' + conv.token, 'conv-message', { message: msg })
sse.broadcast('conversations', 'conv-message', { token: conv.token, customer: conv.customer, customerName: conv.customerName, message: msg })
return msg
}
function registerPush (token, subscription) {
const conv = getConversation(token)
if (!conv) return false
conv.pushSub = subscription
pushSubscriptions.set(token, subscription)
saveToDisk()
log(`Push subscription registered for conv ${token}`)
return true
}
async function sendPushNotification (conv, message) {
if (!conv.pushSub || !cfg.VAPID_PUBLIC_KEY || !cfg.VAPID_PRIVATE_KEY) return false
try {
const webpush = require('web-push')
webpush.setVapidDetails('mailto:support@gigafibre.ca', cfg.VAPID_PUBLIC_KEY, cfg.VAPID_PRIVATE_KEY)
await webpush.sendNotification(conv.pushSub, JSON.stringify({
title: 'Gigafibre', body: message.text?.substring(0, 100) || 'Nouveau message',
url: `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`, tag: 'conv-' + conv.token,
}))
log(`Push sent for conv ${conv.token}`)
return true
} catch (e) {
log(`Push failed for conv ${conv.token}:`, e.message)
if (e.statusCode === 410 || e.statusCode === 404) {
conv.pushSub = null; pushSubscriptions.delete(conv.token); saveToDisk()
}
return false
}
}
async function sendSmsFallback (conv, text, media) {
// Conversation texto : pas de plafond bas sur les réponses MANUELLES de l'agent (chaque envoi = clic humain).
// On garde un plafond de sécurité élevé pour éviter tout emballement.
if ((conv.smsCount || 0) >= 50) { log(`SMS safety cap reached for conv ${conv.token}, skipping`); return false }
const { sendSmsInternal } = require('./twilio')
const success = await sendSmsInternal(conv.phone, text, conv.customer, media)
if (success) { conv.smsCount = (conv.smsCount || 0) + 1; saveToDisk() }
return success
}
async function notifyCustomer (conv, message) {
// Conversation EMAIL → on RÉPOND par courriel dans le MÊME fil Gmail (pas SMS/push).
if (conv.channel === 'email' && conv.email) {
try {
const gmail = require('./gmail')
const base = conv.lastSubject || conv.subject || ''
const subj = withTicketTag(conv, /^re\s*:/i.test(base) ? base : ('Re: ' + base))
const r = await gmail.sendMessage({ to: conv.email, subject: subj, body: message.text || '', html: message.html || '', threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: agentSendFrom(message.agent) })
if (r && r.id) conv.lastGmailId = r.id
log(`Email reply sent for conv ${conv.token}${conv.email} (gmail ${r.id})`)
return 'email'
} catch (e) { log(`Email reply error for conv ${conv.token}: ${e.message}`); return 'none' }
}
// Canaux chat externes : la réponse agent est déjà diffusée en direct via SSE (addMessage → conv-client). Pas de SMS/push.
if (conv.channel === 'webchat' || conv.channel === '3cx') return 'webchat'
if (conv.channel === 'facebook') { log(`FB reply pending (Send API à brancher) conv ${conv.token}`); return 'facebook-pending' } // phase 2 : Messenger Send API
// Si le client a ouvert le web-chat et s'est abonné aux push → notification push.
const pushed = await sendPushNotification(conv, message)
if (pushed) return 'push'
// Sinon : SMS/MMS simple — messagerie texte habituelle, PAS de lien web.
return (await sendSmsFallback(conv, message.text || '', message.media || '')) ? 'sms' : 'none'
}
function findConversationByPhone (phone) {
const digits = phone.replace(/\D/g, '').slice(-10)
for (const [, conv] of conversations) {
if (conv.status !== 'active') continue
if (conv.phone.replace(/\D/g, '').slice(-10) === digits) return conv
}
return null
}
async function autoLinkIncomingSms (phone, text) {
const existing = findConversationByPhone(phone)
if (existing) return existing
const matches = await lookupCustomersByPhone(phone, 6)
const customer = matches.length === 1 ? matches[0] : null // 1 fiche = lien auto ; >1 = l'agent choisit (candidates)
const { sendSmsInternal } = require('./twilio')
if (!customer) {
const conv = createConversation({ phone, customer: null, customerName: phone, agentEmail: null, subject: matches.length > 1 ? `SMS entrant (${matches.length} fiches — à rattacher)` : 'SMS entrant (inconnu)' })
conv.smsCount = 0
if (matches.length > 1) conv.candidates = matches // plusieurs fiches partagent ce numéro → choix dans le panneau OPS
addMessage(conv, { from: 'customer', text, via: 'sms' })
const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
const visibleText = 'Bonjour, merci pour votre message! Un agent Gigafibre vous répondra sous peu.'
// Always add the greeting to the conversation (even if SMS fails)
addMessage(conv, { from: 'agent', text: visibleText, via: 'sms' })
sendSmsInternal(phone, `${visibleText}\n\nPour continuer en ligne:\n${link}`, null).then(sid => {
if (sid) conv.smsCount++
}).catch(e => log('Auto-reply SMS error:', e.message))
log(`Auto-created conv ${conv.token} for unknown caller ${phone}`)
return conv
}
const conv = createConversation({
phone, customer: customer.name, customerName: customer.customer_name || customer.name,
agentEmail: null, subject: 'SMS entrant',
})
conv.smsCount = 0
addMessage(conv, { from: 'customer', text, via: 'sms' })
const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
const firstName = (customer.customer_name || '').split(' ')[0] || ''
const greeting = firstName ? `Bonjour ${firstName}, merci` : 'Bonjour, merci'
const visibleText = `${greeting} pour votre message! Un agent vous répondra sous peu.`
// Always add the greeting to the conversation (even if SMS fails)
addMessage(conv, { from: 'agent', text: visibleText, via: 'sms' })
sendSmsInternal(phone, `${visibleText}\n\nPour continuer la conversation en ligne:\n${link}`, customer.name).then(sid => {
if (sid) {
conv.smsCount++
log(`Auto-reply sent to ${phone} (${sid})`)
}
}).catch(e => log('Auto-reply SMS error:', e.message))
log(`Auto-linked SMS from ${phone} → customer ${customer.name}, conv ${conv.token}`)
return conv
}
const todayET = () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) // YYYY-MM-DD (heure de l'Est)
// Accusé de réception trivial (« ok », « merci », « c'est bon », un emoji…) → rien à traiter, l'IA ne répond pas.
const ACK_WORDS = new Set(['ok', 'okay', 'oki', 'dac', 'daccord', 'accord', 'parfait', 'merci', 'beaucoup', 'nickel', 'super', 'genial', 'bien', 'recu', 'bienrecu', 'compris', 'cest', 'bon', 'cestbon', 'oui', 'ouais', 'yes', 'yep', 'yeah', 'thanks', 'thank', 'you', 'thx', 'ty', 'noted', 'correct', 'exact', 'tres'])
function isAck (text) {
const t = String(text || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') // minuscules, sans accents
if (!t.replace(/[^a-z]/g, '')) return true // emojis/ponctuation seuls (« 👍 », « . », « ✅ ») → accusé
const words = t.replace(/['\-]/g, '').replace(/[^a-z\s]/g, ' ').split(/\s+/).filter(Boolean) // ' et - collés : c'est→cest, d'accord→daccord
return words.length > 0 && words.length <= 3 && words.every(w => ACK_WORDS.has(w))
}
function triggerAgent (conv) {
if (agentTimers.has(conv.token)) clearTimeout(agentTimers.get(conv.token))
agentTimers.set(conv.token, setTimeout(() => {
agentTimers.delete(conv.token)
runAgentForConv(conv)
}, 1500))
}
async function runAgentForConv (conv) {
if (!cfg.AI_API_KEY || !conv.customer || conv.status !== 'active') return
// RÈGLE 1 : l'IA n'auto-répond QUE sur un fil INITIÉ PAR LE CLIENT. Si NOUS l'avons ouvert (notif « maintenance
// planifiée », relance, campagne…), un agent gère — l'IA reste muette (un « ok » du client n'appelle aucune réponse auto).
const initiator = conv.initiatedBy || (conv.messages && conv.messages[0] && conv.messages[0].from) || 'customer'
if (initiator !== 'customer') { log(`AI auto-reply supprimée (fil initié par nous) pour conv ${conv.token}`); return }
// RÈGLE 2 : ne pas répondre à un simple accusé de réception (« ok », « merci »…) — rien à traiter, ça crée du bruit.
const lastCust = [...(conv.messages || [])].reverse().find(m => m.from === 'customer')
if (lastCust && isAck(lastCust.text)) { log(`AI auto-reply supprimée (accusé « ${String(lastCust.text || '').slice(0, 20)} ») pour conv ${conv.token}`); return }
// RÈGLE 3 : si un HUMAIN (agent OPS) a écrit dans ce fil AUJOURD'HUI, l'IA NE répond PAS le même jour (un humain gère).
if (conv.lastHumanDate && conv.lastHumanDate === todayET()) { log(`AI auto-reply supprimée (fil mené par un humain aujourd'hui) pour conv ${conv.token}`); return }
sse.broadcast('conv-client:' + conv.token, 'conv-typing', { typing: true })
try {
const { runAgent } = require('./agent')
const recentMessages = conv.messages.slice(-20).filter(m => m.from === 'customer' || m.from === 'agent')
const reply = await runAgent(conv.customer, conv.customerName, recentMessages, { token: conv.token })
if (reply && conv.status === 'active') {
addMessage(conv, { from: 'agent', text: reply, type: 'text', via: 'ai' })
// If the last customer message came via SMS, send AI reply back via SMS + append web chat link
const lastCustomerMsg = [...conv.messages].reverse().find(m => m.from === 'customer')
if (lastCustomerMsg?.via === 'sms' && conv.phone) {
const chatLink = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
const smsBody = `${reply}\n\nContinuer en ligne: ${chatLink}`
const { sendSmsInternal } = require('./twilio')
sendSmsInternal(conv.phone, smsBody, conv.customer).then(sid => {
if (sid) { conv.smsCount = (conv.smsCount || 0) + 1; log(`AI SMS reply sent to ${conv.phone} (${sid})`) }
}).catch(e => log('AI SMS reply error:', e.message))
}
createCommunication({
communication_type: 'Communication', communication_medium: 'Chat',
sent_or_received: 'Sent', sender: 'ai@gigafibre.ca', sender_full_name: 'Gigafibre Assistant',
phone_no: conv.phone, content: reply, subject: `Chat AI: ${conv.subject}`,
reference_doctype: 'Customer', reference_name: conv.customer, status: 'Linked',
}).catch(() => {})
}
} catch (e) { log('Agent error for conv ' + conv.token + ':', e.message) }
sse.broadcast('conv-client:' + conv.token, 'conv-typing', { typing: false })
}
// ── Coordination (shared inbox) : files = 4 LABELS F (fiable vs 30 départements) + claim ──
const { QUEUES, QUEUE_OF } = require('./categories') // taxonomie = SOURCE UNIQUE (voir lib/categories.js)
const PIPELINE_STAGES = ['Nouveau', 'Qualifié', 'Devis', 'Gagné', 'Perdu'] // pipeline de leads (conversations vente)
// ── Garde-fous anti-spam de file (ingest courriel) ────────────────────────────
// Empêche le blast d'une file pour : nos PROPRES notifs réingérées (boucle), les expéditeurs de NOS domaines
// (staff/systèmes internes), et les RÉPONSES à un ticket existant (→ rattacher au fil, pas un nouveau routage).
// OWN_DOMAINS = source unique dans lib/helpers.js (inclut les SOUS-DOMAINES infra ; jamais un « client »).
const OWN_NOTIF_SUBJECT = /^\s*(re\s*:\s*)?\[(inbox|ticket)\s*[·:]/i // nos sujets [Inbox · …] / [Ticket · …]
const TICKET_REPLY_SUBJECT = /\[ticket\s*#?\s*[\w-]+\]/i // réponse à un ticket existant : [Ticket #ISS-2026-001] ou [Ticket #250819]
// Expéditeur MACHINE (jamais un humain à qui répondre dans l'inbox client) : no-reply, daemon de courriel, panneau d'hébergement…
const AUTOMATED_SENDER = /(no-?reply|do-?not-?reply|mailer-daemon)|^(postmaster|bounces?|daemon|cron)@|@(directadmin|bounces?)\./i // « noreply » n'importe où dans le local-part (ex. mail-noreply@google.com) ; daemon/postmaster en début ; sous-domaines infra
// Sujet caractéristique d'une notif AUTOMATISÉE (monitoring / panneau d'hébergement) — marqueurs HAUTE PRÉCISION seulement.
// NE PAS ajouter de phrases génériques (« service down », « currently down ») : un client peut écrire « mon internet est down » → ne JAMAIS masquer ça.
const AUTOMATED_SUBJECT = /automated message generated by|\bdirectadmin\b|cmd_ticket|freshclam|avis de sécurité.*(serveur mandataire|proxy)/i
const TICKET_REF_RX = /\[ticket\s*#?\s*([\w-]+)\]/i // extrait le NOM du ticket → route le fil vers la bonne conversation
// Préfixe le sujet sortant avec [Ticket #N] quand la conversation est promue en ticket : la réponse du client se rattache au bon fil/ticket.
function withTicketTag (conv, subject) {
const lt = conv && conv.linkedTickets
const tName = (lt && lt.length) ? lt[lt.length - 1].name : null
let s = String(subject || conv.lastSubject || '(sans objet)')
if (!tName) return s
if (new RegExp('\\[ticket\\s*#?\\s*' + tName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\]', 'i').test(s)) return s // déjà étiqueté
const m = s.match(/^(re\s*:\s*)/i); const prefix = m ? m[1] : ''
const rest = s.slice(prefix.length).replace(/\[ticket\s*#?\s*[\w-]+\]\s*/i, '').trim()
return prefix + `[Ticket #${tName}] ` + rest
}
// Nom d'affichage de l'expéditeur depuis l'en-tête From (« Michaël Brisson <x@y> » → « Michaël Brisson »). '' si pas de nom (juste un courriel) → on retombe sur l'email.
function fromDisplayName (from) {
const s = String(from || '').trim()
const m = /^\s*"?([^"<]*?)"?\s*<[^>]+>/.exec(s) // « Nom <email> » ou « "Nom" <email> »
let n = m ? m[1].trim() : (!/@/.test(s) ? s : '')
n = n.replace(/^['"]|['"]$/g, '').trim()
return (n && !/@/.test(n)) ? n.slice(0, 80) : ''
}
// Nom d'affichage de l'agent pour le From sortant, dérivé de son courriel SSO : gilles.drolet@targo.ca → « Gilles Drolet » (gilles@ → « Gilles »).
function agentDisplayName (email) {
const local = String(email || '').split('@')[0]
return local.split(/[._-]+/).filter(Boolean).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ')
}
// From sortant PERSONNALISÉ : « Gilles Drolet » <support@targo.ca>. L'ADRESSE reste l'alias surveillé (les réponses reviennent dans l'inbox partagé) ; seul le nom affiché change. Sans nom/agent → undefined ⇒ défaut Gmail (« Service TARGO »).
function agentSendFrom (email) {
const name = agentDisplayName(email)
if (!name) return undefined
const base = require('./gmail').sendFrom()
const addr = (String(base).match(/<([^>]+)>/) || [null, base])[1].trim()
return `"${name.replace(/["<>]/g, '')}" <${addr}>`
}
function ingestGuard (email, subject) {
const s = String(subject || '')
const e = String(email || '').trim()
if (OWN_NOTIF_SUBJECT.test(s)) return 'own_notif' // notre propre courriel de file → boucle
if (e && OWN_DOMAINS.test(e)) return 'internal' // expéditeur interne (incl. sous-domaines infra), pas un client
if ((e && AUTOMATED_SENDER.test(e)) || AUTOMATED_SUBJECT.test(s)) return 'automated' // notif machine (no-reply / monitoring / hébergement) → masquée de l'inbox client
if (TICKET_REPLY_SUBJECT.test(s)) return 'ticket_reply' // suivi d'un ticket → fil existant, pas de blast
return null
}
function gmailReplyUrl (conv) { return (conv && conv.channel === 'email' && conv.lastMessageId) ? 'https://mail.google.com/mail/u/0/#search/rfc822msgid:' + encodeURIComponent(String(conv.lastMessageId).replace(/^<|>$/g, '')) : null }
// Appartenance agent ↔ file : config éditable /app/data/queue_members.json = { "Supports": ["a@targo.ca", …], … }. Vide = aucune notif (sûr).
function loadQueueMembers () { return readJsonFile('/app/data/queue_members.json', {}) }
// Règles d'inbox définies par l'agent (« Filtrer comme ceci ») : [{ id, field:'from'|'subject', contains, action:'mask'|'allow' }]. « allow » prime sur « mask ».
function loadInboxRules () { return readJsonFile('/app/data/inbox_rules.json', []) }
function matchInboxRule (email, subject) {
const rules = loadInboxRules(); if (!Array.isArray(rules) || !rules.length) return null
const e = String(email || '').toLowerCase(); const s = String(subject || '').toLowerCase()
let masked = false
for (const r of rules) {
const needle = String(r.contains || '').toLowerCase().trim(); if (!needle) continue
const hay = r.field === 'subject' ? s : e
if (hay.includes(needle)) { if (r.action === 'allow') return 'allow'; if (r.action === 'mask') masked = true }
}
return masked ? 'mask' : null
}
// Libellés d'AFFICHAGE des files (ex. « Facturation » au singulier) — la CLÉ reste le nom réel (pluriel, = F). Cosmétique uniquement.
function loadQueueLabels () { return readJsonFile('/app/data/queue_labels.json', {}) }
// Réponses pré-écrites (canned) : /app/data/canned_responses.json = [{id,title,body,category}]. Variables {{client}} {{agent}} {{ticket}} résolues à l'insertion (front).
function loadCanned () { const d = readJsonFile('/app/data/canned_responses.json', []); return Array.isArray(d) ? d : (d.responses || []) }
function saveCanned (list) { return writeJsonFile('/app/data/canned_responses.json', list || []) }
// Pousse un courriel (via Gmail) aux MEMBRES de la file quand une demande y arrive (1 fois). Remplace le fan-out support@ par un ciblage par équipe.
async function notifyQueue (conv) {
if (!conv || !conv.queue) return
conv._notifiedQueues = conv._notifiedQueues || []
if (conv._notifiedQueues.includes(conv.queue)) return
const members = (loadQueueMembers()[conv.queue] || []).filter(e => /.+@.+\..+/.test(e))
if (!members.length) return // aucun membre configuré → on ne notifie pas
const lastCust = [...(conv.messages || [])].reverse().find(m => m.from === 'customer')
const link = gmailReplyUrl(conv) || ''
const who = conv.customerName || conv.email || conv.phone || 'Client'
const body = `Nouvelle demande dans la file « ${conv.queue} ».\n\nDe : ${who}\nSujet : ${conv.subject || '(courriel)'}\n\n${String((lastCust && lastCust.text) || '').slice(0, 600)}\n\n${link ? 'Ouvrir le fil : ' + link : 'À prendre en charge dans OPS — Inbox.'}\n\n— TARGO Inbox (assignation automatique)`
try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Inbox · ${conv.queue}] ${who}: ${String(conv.subject || '').slice(0, 60)}`, body }, { kind: 'queue-notif', label: conv.queue }); conv._notifiedQueues.push(conv.queue); saveToDisk() } catch (e) { log('notifyQueue ' + conv.token + ': ' + e.message) }
}
// Notifie une file PRÉCISE pour un ticket donné (utilisé par le split : chaque équipe reçoit SON ticket, indépendamment de conv.queue).
async function notifyQueueLabel (label, conv, ticketSubject) {
const members = (loadQueueMembers()[label] || []).filter(e => /.+@.+\..+/.test(e))
if (!members.length) return
const who = (conv && (conv.customerName || conv.email || conv.phone)) || 'Client'
const link = gmailReplyUrl(conv) || ''
const body = `Nouveau ticket assigné à la file « ${label} » (issu d'une conversation scindée).\n\nDe : ${who}\nTicket : ${ticketSubject}\n\n${link ? 'Fil d\'origine : ' + link : 'À traiter dans OPS — Tickets / Inbox.'}\n\n— TARGO Inbox`
try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Inbox · ${label}] ${ticketSubject}`.slice(0, 90), body }, { kind: 'queue-notif', label }) } catch (e) { log('notifyQueueLabel ' + label + ': ' + e.message) }
}
function listConversations ({ includeAll = false } = {}) {
const out = []
for (const [, conv] of conversations) {
const expired = new Date(conv.expiresAt) < new Date()
if (!includeAll && (conv.status !== 'active' || expired)) continue
out.push({
token: conv.token, phone: conv.phone, customer: conv.customer,
customerName: conv.customerName, agentEmail: conv.agentEmail, subject: conv.subject,
channel: conv.channel || 'sms', email: conv.email || null, threadId: conv.threadId || null, noise: !!conv.noise,
queue: conv.queue || '', assignee: conv.assignee || null,
suggestedTicket: conv.suggestedTicket || null, linkedTickets: conv.linkedTickets || [],
status: expired ? 'expired' : conv.status, messageCount: conv.messages.length,
lastActivity: conv.lastActivity, createdAt: conv.createdAt,
lastMessage: conv.messages.length ? conv.messages[conv.messages.length - 1] : null,
})
}
out.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity))
const discussions = {}
for (const c of out) {
const isEmail = c.channel === 'email'
const phoneKey = c.phone ? c.phone.replace(/\D/g, '').slice(-10) : '' // null-safe (les convs EMAIL n'ont pas de téléphone)
// Regroupement courriel : par FIL GMAIL d'abord (client + réponses équipe + copies CC = même thread), sinon par courriel CLIENT (jamais une de NOS adresses → support@targointernet.com ne fusionne plus des clients distincts), sinon fil isolé.
const ownEmail = isEmail && c.email && OWN_DOMAINS.test(c.email)
const baseKey = isEmail
? (c.threadId ? ('thr:' + c.threadId) : (c.email && !ownEmail ? ('email:' + c.email) : ('t:' + c.token)))
: (phoneKey || ('t:' + c.token))
const key = c.status === 'active' ? `${baseKey}:active` : `${baseKey}:${c.createdAt.slice(0, 10)}`
if (!discussions[key]) {
const day = c.status === 'active' ? c.lastActivity.slice(0, 10) : c.createdAt.slice(0, 10)
discussions[key] = {
id: key, phone: c.phone, email: c.email || null, channel: c.channel || 'sms',
customer: c.customer, customerName: c.customerName, queue: c.queue || '', assignee: c.assignee || null,
date: day, conversations: [], messages: [], lastActivity: c.lastActivity, status: 'closed',
}
}
discussions[key].conversations.push(c)
if (c.status === 'active') discussions[key].status = 'active'
if (c.queue && !discussions[key].queue) discussions[key].queue = c.queue
if (c.assignee) discussions[key].assignee = c.assignee
if (c.lastActivity > discussions[key].lastActivity) {
discussions[key].lastActivity = c.lastActivity
if (discussions[key].status === 'active') discussions[key].date = c.lastActivity.slice(0, 10)
}
}
for (const d of Object.values(discussions)) {
const allMsgs = []; let latest = null
for (const c of d.conversations) {
const conv = conversations.get(c.token)
if (!conv) continue
if (!latest || conv.lastActivity > latest.lastActivity) latest = conv
for (const m of conv.messages) allMsgs.push({ ...m, convToken: c.token })
}
allMsgs.sort((a, b) => a.ts.localeCompare(b.ts))
d.messages = allMsgs
d.messageCount = allMsgs.length
d.subject = (latest && (latest.lastSubject || latest.subject)) || '' // sujet du courriel pour la liste (était absent → la liste n'affichait pas le sujet)
d.lastMessage = allMsgs.length ? allMsgs[allMsgs.length - 1] : null
d.token = latest ? latest.token : (d.conversations[0] && d.conversations[0].token) // token actionnable (claim/file/réponse)
d.suggestedTicket = (latest && latest.suggestedTicket) || null
d.linkedTickets = (latest && latest.linkedTickets) || []
d.gmailReplyUrl = gmailReplyUrl(latest)
d.noise = !!(latest && latest.noise)
d.readBy = (latest && latest.readBy) || {} // liste : « vu par » + détection non-lu par soi
}
return { conversations: out, discussions: Object.values(discussions).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)) }
}
// Met à la CORBEILLE Gmail les courriels du fil (réversible 30 j) — fil entier si threadId connu, sinon par message.
async function trashConvGmail (conv) {
if (!conv || conv.channel !== 'email') return
const gmail = require('./gmail')
if (conv.threadId) { await gmail.trashThread(conv.threadId); return }
for (const id of (conv._gmailIds && conv._gmailIds.length ? conv._gmailIds : (conv.lastGmailId ? [conv.lastGmailId] : []))) { try { await gmail.trashMessage(id) } catch (e) { /* */ } }
}
// Supprime la conversation. opts.trashGmail = sort aussi les courriels liés de la boîte (suppression EXPLICITE par l'agent).
function deleteConversation (token, { trashGmail = false } = {}) {
const conv = conversations.get(token)
if (!conv) return false
if (trashGmail) trashConvGmail(conv).catch(e => log('trashConvGmail ' + token + ': ' + e.message)) // best-effort, ne bloque pas la suppression
conversations.delete(token)
saveToDisk()
return true
}
function deleteDiscussionByTokens (tokens) {
let count = 0
for (const t of tokens) if (conversations.delete(t)) count++
if (count) saveToDisk()
return count
}
function deleteDiscussion (phone, date) {
const phoneDigits = phone.replace(/\D/g, '').slice(-10)
const tokensToDelete = []
for (const [token, conv] of conversations) {
if (conv.phone.replace(/\D/g, '').slice(-10) !== phoneDigits) continue
if (date === 'active') { if (conv.status === 'active') tokensToDelete.push(token) }
else if (conv.createdAt.slice(0, 10) === date && conv.status !== 'active') tokensToDelete.push(token)
}
for (const t of tokensToDelete) conversations.delete(t)
if (tokensToDelete.length) saveToDisk()
return tokensToDelete.length
}
async function archiveDiscussion (tokens) {
const allMsgs = []
let customer = null, customerName = null, phone = null, subject = null
for (const token of tokens) {
const conv = conversations.get(token)
if (!conv) continue
if (!customer && conv.customer) customer = conv.customer
if (!customerName && conv.customerName) customerName = conv.customerName
if (!phone) phone = conv.phone
if (!subject && conv.subject) subject = conv.subject
for (const m of conv.messages) allMsgs.push(m)
}
if (!allMsgs.length) return null
// If no customer linked, try to lookup by phone number
if (!customer && phone) {
try {
const found = await lookupCustomerByPhone(phone)
if (found) {
customer = found.name
if (!customerName || customerName === phone) customerName = found.customer_name || found.name
}
} catch {}
}
allMsgs.sort((a, b) => a.ts.localeCompare(b.ts))
const lines = allMsgs.map(m => {
if (m.from === 'system') return `--- ${m.text} ---`
const sender = m.from === 'customer' ? (customerName || phone || 'Client')
: m.via === 'ai' ? 'AI Assistant' : 'Agent'
const time = new Date(m.ts).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' })
const via = m.via === 'sms' ? ' [SMS]' : m.via === 'ai' ? ' [AI]' : ''
return `**${sender}**${via} (${time}):\n${m.text}`
})
const issueSubject = `[Archivé] ${subject || 'Discussion'}${customerName || phone}`
const issueData = {
subject: issueSubject, status: 'Closed', priority: 'Low', // issue_type omis (doctype « SMS/Chat » absent → LinkValidationError)
description: (phone ? `Téléphone : ${phone}\n\n` : '') + lines.join('\n\n'),
}
// Only set customer if we have a valid one — avoid sending null/undefined to ERPNext
if (customer) issueData.customer = customer
if (phone && /.+@.+\..+/.test(phone)) issueData.raised_by = phone // raised_by = EMAIL valide seulement
try {
const result = await erp.create('Issue', issueData)
if (!result.ok) throw new Error(result.error || 'Issue create failed')
const issueName = result.name
log(`Archived ${tokens.length} conversations → Issue ${issueName} (customer: ${customer || 'none'})`)
deleteDiscussionByTokens(tokens)
return { issue: issueName, messagesArchived: allMsgs.length, customer: customer || null }
} catch (e) { log('Archive error:', e.message); throw e }
}
// Crée un ERPNext Issue OUVERT depuis une conversation, SANS la supprimer (≠ archiveDiscussion qui ferme+supprime).
// La conversation RESTE dans l'historique du client ; lien bidirectionnel (conv.linkedTickets ↔ Issue.conversation_token).
async function createTicketFromConversation (token, { title, category, priority, summary } = {}) {
const conv = getConversation(token)
if (!conv) return { ok: false, error: 'Conversation introuvable' }
const who = (m) => m.from === 'customer' ? (conv.customerName || conv.phone || 'Client') : (m.via === 'ai' ? 'AI Assistant' : 'Agent')
// Nettoie un corps de message pour le ticket : décode les entités HTML (&gt;…), COUPE l'historique cité (réponses Gmail/Outlook), retire les lignes « > … ». Garde le NOUVEAU texte lisible.
const clean = (text) => {
let s = String(text || '').replace(/^✉️[^\n]*\n+/, '')
s = s.replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&nbsp;/g, ' ').replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/&amp;/g, '&')
s = s.split(/(?:On .{0,90}?wrote\s*:|Le .{0,90}?a écrit\s*:|-{2,}\s*Original Message|_{5,})/i)[0] // jette la citation, garde la réponse
s = s.split('\n').filter(l => !/^\s*>+/.test(l)).join('\n')
return s.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
}
const cleanSubject = (s) => String(s || '').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&').split(/\s+On [A-Z]\w+,|\s+Le \d|[\r\n]/)[0].replace(/\s+/g, ' ').trim()
const msgs = (conv.messages || []).slice().sort((a, b) => String(a.ts).localeCompare(String(b.ts)))
const lines = msgs.filter(m => m.from !== 'system').map(m => {
const time = new Date(m.ts).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' })
const via = m.via === 'sms' ? 'SMS' : m.via === 'ai' ? 'IA' : m.via === 'email' ? 'Courriel' : 'Web'
let body = clean(m.text)
if (m.media) body += (body ? '\n' : '') + '🖼️ Image : ' + m.media // URL conservée (cliquable depuis le ticket)
if (!body && m.html) body = '[courriel HTML — voir le fil de la conversation]'
return `── ${who(m)} · ${via} · ${time}\n${body || '[vide]'}`
})
const cat = String(category || '').trim()
const subject = (cat ? `[${cat}] ` : '') + (cleanSubject(title || conv.lastSubject || conv.subject) || 'Demande client').slice(0, 130)
const issueData = {
subject, status: 'Open', priority: priority || 'Medium', // issue_type omis : le doctype Issue Type « SMS/Chat » n'existe pas → la catégorie est dans le sujet [Cat]
description: (summary ? 'Résumé IA :\n' + clean(summary) + '\n\n═══════════\n\n' : '') + `Source : conversation #${conv.token}` + (conv.customerName ? ` · ${conv.customerName}` : '') + (conv.phone ? ` · ${conv.phone}` : '') + '\n\n' + lines.join('\n\n'),
}
if (conv.customer) issueData.customer = conv.customer
if (conv.email && /.+@.+\..+/.test(conv.email)) issueData.raised_by = conv.email // raised_by = EMAIL valide seulement (sinon InvalidEmailAddressError)
const r = await erp.create('Issue', issueData)
if (!r.ok) return { ok: false, error: r.error || 'Création Issue échouée' }
conv.linkedTickets = conv.linkedTickets || []
conv.linkedTickets.push({ name: r.name, subject, ts: new Date().toISOString() })
saveToDisk()
erp.update('Issue', r.name, { conversation_token: conv.token }).catch(() => {}) // lien retour (best-effort si champ custom présent)
addMessage(conv, { from: 'system', text: `🎫 Ticket ${r.name} créé depuis cette conversation` })
sse.broadcast('conversations', 'conv-ticket-linked', { token: conv.token, issue: r.name, subject })
log(`Ticket ${r.name} créé depuis conv ${conv.token} (customer: ${conv.customer || 'none'})`)
return { ok: true, issue: r.name, subject }
}
// CHAÎNE DE TICKET : quand une conversation est liée à un Issue, chaque courriel (entrant/sortant) y est journalisé
// comme Communication (reference_doctype Issue) → le ticket porte tout le fil, sans doublon de saisie.
async function logToLinkedTicket (conv, msg, subject) {
if (!conv || !conv.linkedTickets || !conv.linkedTickets.length) return
const issue = conv.linkedTickets[conv.linkedTickets.length - 1] // le plus récent
if (!issue || !issue.name) return
const sent = msg.from === 'agent'
try {
await createCommunication({
communication_type: 'Communication', communication_medium: 'Email',
sent_or_received: sent ? 'Sent' : 'Received',
sender: sent ? (cfg.MAIL_FROM || 'cc@targointernet.com') : (conv.email || ''),
recipients: sent ? (conv.email || '') : (cfg.MAIL_FROM || 'cc@targointernet.com'),
subject: 'Re: ' + (subject || conv.lastSubject || conv.subject || ''),
content: msg.html || (msg.text || '').replace(/\n/g, '<br>'),
reference_doctype: 'Issue', reference_name: issue.name, status: 'Linked',
})
} catch (e) { log('logToLinkedTicket ' + issue.name + ': ' + e.message) }
}
// PREUVE DE PAIEMENT : OCR (vision Gemini) d'une image envoyée par le client → extrait montant/date/réf → AFFICHE pour décider.
// NE écrit RIEN en facturation (F autoritaire) et NE réactive pas (GenieACS pas prêt) : on route vers Facturation, l'humain décide.
async function analyzePaymentProof (token, { image, messageId } = {}) {
const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' }
let src = image
if (!src && messageId) { const m = (conv.messages || []).find(x => x.id === messageId); src = m && m.media }
if (!src) { const m = [...(conv.messages || [])].reverse().find(x => x.media); src = m && m.media }
if (!src) return { ok: false, error: 'Aucune image à analyser' }
let base64
try {
if (/^https?:\/\//.test(src)) { const r = await fetch(src); const buf = Buffer.from(await r.arrayBuffer()); base64 = buf.toString('base64') }
else base64 = stripDataUrl(src)
} catch (e) { return { ok: false, error: 'image illisible: ' + e.message } }
let result
try { result = await require('./vision').extractPayment(base64) } catch (e) { return { ok: false, error: 'OCR: ' + e.message } }
conv.paymentProof = { ...result, ts: new Date().toISOString(), messageId: messageId || null }
saveToDisk()
sse.broadcast('conversations', 'conv-payment', { token: conv.token, payment: conv.paymentProof })
log(`Payment proof conv ${conv.token}: is_payment=${result.is_payment} amount=${result.amount} conf=${result.confidence}`)
return { ok: true, payment: conv.paymentProof }
}
// Trouve une conversation EMAIL existante par adresse (canal email) — pour regrouper le fil d'un même expéditeur.
function findConversationByEmail (email) {
if (!email) return null
const e = String(email).toLowerCase()
let best = null
for (const c of conversations.values()) {
if (c.status === 'expired') continue
if (String(c.email || '').toLowerCase() === e) { if (!best || String(c.lastActivity) > String(best.lastActivity)) best = c }
}
return best
}
// Regroupement PAR FIL GMAIL (thread_id) — la VRAIE clé d'une discussion : le client, nos réponses et les copies CC à support@ partagent le même thread_id Gmail, peu importe l'expéditeur de chaque message. Évite aussi de fusionner des fils distincts qui partagent une adresse interne (support@targointernet.com).
function findConversationByThreadId (threadId) {
if (!threadId) return null
let best = null
for (const c of conversations.values()) {
if (c.status === 'expired') continue
if (c.threadId && String(c.threadId) === String(threadId)) { if (!best || String(c.lastActivity) > String(best.lastActivity)) best = c }
}
return best
}
// Trouve la conversation liée à un ticket donné — pour rattacher une réponse « [Ticket #N] » au bon fil, même hors fil Gmail.
function findConversationByTicketName (name) {
if (!name) return null
const n = String(name).toLowerCase()
let best = null
for (const c of conversations.values()) {
if (c.status === 'expired' || c.status === 'merged') continue
if ((c.linkedTickets || []).some(t => String(t.name || '').toLowerCase() === n)) { if (!best || String(c.lastActivity) > String(best.lastActivity)) best = c }
}
return best
}
// INGESTION d'un courriel entrant (poussé par n8n ou un poller IMAP). Crée/append une conversation canal EMAIL,
// lance le triage IA, et si pertinent attache une SUGGESTION de ticket (affichée dans l'Inbox : Créer / Ignorer).
async function ingestEmail ({ from, subject, body, text, html, message_id, thread_id, gmail_id } = {}) {
const triage = require('./inbox-triage')
const email = triage.extractEmail(from)
const content = String(body || text || '').slice(0, 8000)
if (message_id) { for (const c of conversations.values()) { if (Array.isArray(c._emailMsgIds) && c._emailMsgIds.includes(message_id)) return { ok: true, dedup: true, token: c.token } } } // idempotent
const guard = ingestGuard(email, subject) // garde-fous anti-spam de file (boucle / interne / réponse-ticket)
if (guard === 'own_notif') { log('ingestEmail: skip own notif loop — ' + String(subject || '').slice(0, 60)); return { ok: true, skipped: 'own_notif' } } // nos propres courriels de file → ne pas réingérer
const t = await triage.classifyEmail({ from, subject, body: content }).catch(e => { log('ingestEmail triage error:', e.message); return null })
// Réponse à un ticket promu : « [Ticket #N] » dans le sujet → rattache au fil exact (robuste même hors fil Gmail).
const tref = (String(subject || '').match(TICKET_REF_RX) || [])[1]
// On ne REGROUPE jamais par une de NOS adresses (support@targo.ca, *@targointernet.com…) : sinon tout le sortant ré-ingéré (From: support@targo.ca) se collapse dans un seul fil → des conversations sans lien fusionnent + héritent d'un mauvais nom. Voir OWN_DOMAINS.
let conv = (tref && findConversationByTicketName(tref))
|| (thread_id && findConversationByThreadId(thread_id)) // MÊME fil Gmail = MÊME discussion (client + nos réponses + copies CC à support@), peu importe l'expéditeur de chaque message
|| (email && !OWN_DOMAINS.test(email) ? findConversationByEmail(email) : null)
const isNewConv = !conv
if (!conv) {
// Identité d'affichage = qui a VRAIMENT envoyé (en-tête From) en priorité, puis une fiche client liée PAR COURRIEL (fiable), puis le courriel.
// On n'utilise PAS le nom DEVINÉ par l'IA depuis le CONTENU (t.customer_name sans fiche liée) comme identité → sinon un courriel « gilles@ » s'affiche « Sylvie Juteau ».
const linkedName = (t && t.customer) ? t.customer_name : ''
conv = createConversation({ phone: null, customer: (t && t.customer) || null, customerName: fromDisplayName(from) || linkedName || email || 'Courriel', agentEmail: null, subject: subject || '(courriel)' })
conv.channel = 'email'; conv.email = email
}
conv._emailMsgIds = conv._emailMsgIds || []; if (message_id) conv._emailMsgIds.push(message_id)
if (thread_id) conv.threadId = thread_id // pour répondre DANS le même fil Gmail
if (message_id) conv.lastMessageId = message_id // In-Reply-To/References
if (gmail_id) { conv.lastGmailId = gmail_id; conv._gmailIds = conv._gmailIds || []; if (!conv._gmailIds.includes(gmail_id)) conv._gmailIds.push(gmail_id) } // pour spam/corbeille Gmail ciblés
conv.lastSubject = subject || conv.lastSubject || conv.subject
// Dédup DOUBLE-ENVOI : même expéditeur, corps QUASI identique, < 3 min (Message-ID différent → la dédup par ID ne l'attrape pas).
// FLOU : certains clients renvoient le même courriel à ~2 s d'intervalle avec 1 caractère de différence (encodage) → comparer les 140 premiers car. + écart de longueur < 12 %, pas un === strict.
const _norm = s => String(s || '').replace(/\s+/g, ' ').trim()
const _last = conv.messages.length ? conv.messages[conv.messages.length - 1] : null
if (_last && _last.from === 'customer') {
const a = _norm(_last.text), b = _norm(content)
const near = !!a && !!b && (a === b || (a.length > 80 && b.length > 80 && a.slice(0, 140) === b.slice(0, 140) && Math.abs(a.length - b.length) <= Math.max(a.length, b.length) * 0.12))
if (near && (Date.now() - new Date(_last.ts).getTime()) < 180000) {
log('ingestEmail: skip double-envoi (quasi-identique < 3 min) — ' + email); saveToDisk()
return { ok: true, skipped: 'double_send', token: conv.token }
}
}
// Corps = message du fil (le sujet est affiché comme EN-TÊTE → on ne le recolle PAS dans le corps : évite la double en-tête).
// Expéditeur d'un de NOS domaines (membre de l'équipe qui répond via Gmail, en copie CC à support@) → message AGENT, pas « customer » (sinon il s'affiche comme un message du client dans le fil).
const senderIsOwn = !!(email && OWN_DOMAINS.test(email))
const emailMsg = addMessage(conv, { from: senderIsOwn ? 'agent' : 'customer', text: content, via: 'email', html: html || '', agent: senderIsOwn ? email : '', fromName: fromDisplayName(from), fromEmail: email, gmail_id: gmail_id || '' })
if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, emailMsg, subject).catch(() => {}) // chaîne : alimente le ticket lié
// ── MASQUAGE + ROUTAGE : décidés UNE SEULE FOIS, à la CRÉATION du fil ──
// Sur une RÉPONSE à un fil existant (même thread Gmail), on NE ré-évalue PAS : sinon une réponse interne de l'équipe (guard 'internal') pourrait MASQUER un fil client visible, ou re-router / écraser le triage du client.
if (isNewConv) {
// Auto-masque le bruit machine (no-reply/infra) et l'interne, MAIS JAMAIS un vrai lead détecté par l'IA (vente/support/facturation/installation…). Les RÈGLES utilisateur priment.
const LEAD_TYPES = ['vente', 'support', 'facturation', 'installation', 'telephonie', 'television']
const isLead = !!(t && !t.noise && LEAD_TYPES.includes(t.type))
let masked = !!(t && t.noise) || guard === 'automated' || (guard === 'internal' && !isLead)
const rule = matchInboxRule(email, subject) // 'allow' | 'mask' | null
if (rule === 'allow') masked = false
else if (rule === 'mask') masked = true
conv.noise = masked
if (t) {
conv.triage = t
if (!conv.noise && guard !== 'ticket_reply') { // visible et pas une réponse à un ticket existant → routage/suggestion auto OK
if (!conv.queue) conv.queue = QUEUE_OF[t.type] || '' // route vers le LABEL (file) ; réorientable d'un clic
if (t.suggest_ticket && !(conv.linkedTickets && conv.linkedTickets.length)) {
conv.suggestedTicket = { title: t.suggested_title, category: t.category, priority: 'Medium', reason: t.reason, is_customer: t.is_customer, ts: new Date().toISOString() }
}
if (t.type === 'vente' && !conv.pipeline) conv.pipeline = { stage: 'Nouveau', value: 0, updatedAt: new Date().toISOString() } // lead → entre dans le pipeline
}
}
} else {
// Fil existant : une règle utilisateur EXPLICITE peut tout de même ré-afficher/masquer ; sinon on ne touche pas à la visibilité ni au routage.
const rule = matchInboxRule(email, subject)
if (rule === 'allow') conv.noise = false
else if (rule === 'mask') conv.noise = true
}
try { require('./coupon-triage').onEmail({ from, subject, body: content, conv }) } catch (e) { log('coupon onEmail: ' + e.message) } // Levier 1 : alerte coupons (ne bloque pas l'ingest)
saveToDisk()
sse.broadcast('conversations', 'conv-email', { token: conv.token, email, triage: t })
if (conv.queue && !conv.noise && !conv.assignee && !guard) notifyQueue(conv).catch(() => {}) // pousse aux membres de la file ; JAMAIS sur interne/réponse-ticket/notif (anti-spam)
return { ok: true, token: conv.token, triage: t, suggested_ticket: !!(conv.suggestedTicket) }
}
// INGESTION CANAL GÉNÉRIQUE — web chat (nouveau site), 3CX, Facebook Messenger… Tout tombe dans l'INBOX PARTAGÉ
// (file + claim), donc AUCUNE capture mono-agent : si l'agent est absent et que le client réécrit, le fil reste visible par tous.
// Clé de regroupement = channel + external_id (session web / PSID Facebook) → un seul fil par interlocuteur, réponses routées.
function findConversationByChannelKey (key) {
if (!key) return null
let best = null
for (const c of conversations.values()) { if (c.channelKey === key && (!best || String(c.lastActivity) > String(best.lastActivity))) best = c }
return best
}
async function channelIngest ({ channel, external_id, from, name, text, html, customer, message_id } = {}) {
const ch = String(channel || 'webchat').toLowerCase()
const key = ch + ':' + String(external_id || from || '').trim()
const content = String(text || '').slice(0, 8000)
if (message_id) { for (const c of conversations.values()) { if (Array.isArray(c._chanMsgIds) && c._chanMsgIds.includes(message_id)) return { ok: true, dedup: true, token: c.token } } }
let conv = findConversationByChannelKey(key)
if (!conv) {
conv = createConversation({ phone: null, customer: customer || null, customerName: name || from || ({ webchat: 'Visiteur web', facebook: 'Messenger', '3cx': 'Chat 3CX' }[ch] || 'Chat'), subject: ({ webchat: 'Chat site', facebook: 'Facebook Messenger', '3cx': 'Chat 3CX' }[ch] || 'Chat') })
conv.channel = ch; conv.channelKey = key; conv.channelExternalId = String(external_id || from || '')
}
conv._chanMsgIds = conv._chanMsgIds || []; if (message_id) conv._chanMsgIds.push(message_id)
const msg = addMessage(conv, { from: 'customer', text: content, via: ch, html: html || '' })
// Triage IA léger (réutilise le classifieur courriel) → file + suggestion de ticket, comme un courriel.
try {
const t = await require('./inbox-triage').classifyEmail({ from: from || name || ch, subject: conv.subject, body: content })
if (t) { conv.triage = t; conv.noise = !!t.noise; if (!t.noise && !conv.queue) conv.queue = QUEUE_OF[t.type] || '' ; if (!t.noise && t.suggest_ticket && !(conv.linkedTickets && conv.linkedTickets.length)) conv.suggestedTicket = { title: t.suggested_title, category: t.category, priority: 'Medium', reason: t.reason, ts: new Date().toISOString() } }
} catch (e) { /* triage best-effort */ }
if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, msg, conv.subject).catch(() => {})
saveToDisk()
sse.broadcast('conversations', 'conv-message', { token: conv.token, customer: conv.customer, customerName: conv.customerName, message: msg })
if (conv.queue && !conv.noise && !conv.assignee) notifyQueue(conv).catch(() => {}) // file notifiée → personne n'est « propriétaire » unique
return { ok: true, token: conv.token, channel: ch }
}
// Envoi SMS sortant réutilisable (orchestrateur NL, FAB…) : crée/append une conversation par téléphone + envoie (notifyCustomer).
async function sendSms ({ phone, customer, customerName, message, media } = {}) {
const digits = String(phone || '').replace(/\D/g, '')
if (digits.length < 10) return { ok: false, error: 'numéro invalide' }
if (!message && !media) return { ok: false, error: 'message requis' }
let conv = findConversationByPhone(digits)
if (!conv) conv = createConversation({ phone: digits, customer: customer || null, customerName: customerName || digits, subject: 'SMS' })
else if (customer && !conv.customer) { conv.customer = customer; conv.customerName = customerName || conv.customerName }
const msg = addMessage(conv, { from: 'agent', text: message || '', via: 'web', type: media ? 'image' : 'text', media: media || '' })
msg.notifiedVia = await notifyCustomer(conv, msg)
conv.lastHumanDate = todayET(); saveToDisk()
return { ok: true, token: conv.token, via: msg.notifiedVia }
}
// Compose un NOUVEAU courriel SORTANT (≠ réponse) : envoie via Gmail + crée/append une conversation EMAIL suivie.
async function sendNewEmail ({ to, subject, body, html, customer, customerName, agentEmail } = {}) {
const triage = require('./inbox-triage')
const dest = triage.extractEmail(to) || String(to || '').trim()
if (!dest || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'destinataire courriel invalide' }
const gmail = require('./gmail')
let conv = findConversationByEmail(dest)
if (!conv) { conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || dest, subject: subject || 'Courriel' }); conv.channel = 'email'; conv.email = dest }
// réponse dans le fil si la conversation existe déjà (threadId connu)
const r = await gmail.sendMessage({ to: dest, subject: withTicketTag(conv, subject || conv.lastSubject || '(sans objet)'), body: body || '', html: html || '', threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: agentSendFrom(agentEmail) })
if (r && r.threadId) conv.threadId = r.threadId
if (r && r.id) conv.lastGmailId = r.id
conv.lastSubject = subject || conv.lastSubject
const m = addMessage(conv, { from: 'agent', text: (body || ''), via: 'email', html: html || '', agent: agentEmail || '' })
if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, m, subject).catch(() => {})
conv.lastHumanDate = todayET(); saveToDisk()
log(`Nouveau courriel sortant → ${dest} (gmail ${r && r.id})`)
return { ok: true, token: conv.token, gmail: r }
}
// ── SPLIT : détecte les sujets distincts d'une conversation (ex. « facturation » + « télévision ») ──
// detectTopics ne crée RIEN (aperçu) ; splitConversation crée 1 ticket par sujet (réutilise createTicketFromConversation).
async function detectSplitTopics (token) {
const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' }
if (!cfg.AI_API_KEY) return { ok: false, error: 'IA indisponible' }
const msgs = (conv.messages || []).filter(m => m.from !== 'system').slice(-12).map(m => (m.from === 'customer' ? 'CLIENT' : 'AGENT') + ': ' + String(m.text || '').slice(0, 400)).join('\n')
const sys = 'Tu analyses une conversation de support TARGO/Gigafibre (FAI). Détecte les PROBLÈMES/DEMANDES DISTINCTS qu\'elle contient. Renvoie UNIQUEMENT du JSON : {"topics":[{"title":"titre court du problème","category":"Facturation|Support|Vente|Installation|Television|Telephonie|Autre","queue":"Facturations|Service à la clientèle|Supports|Technicien"}]}. UN SEUL sujet ⇒ un seul élément. Ne scinde QUE si les sujets sont réellement indépendants (ex. une question de facture ET un problème de télé). N\'invente rien.'
let out
try {
out = await require('./ai').chat({ task: 'split', maxTokens: 500, temperature: 0.1, messages: [{ role: 'system', content: sys }, { role: 'user', content: 'CONVERSATION :\n' + msgs }] })
} catch (e) { return { ok: false, error: 'IA : ' + e.message } }
let parsed; try { parsed = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0]) } catch (e) { return { ok: false, error: 'réponse IA illisible' } }
const topics = Array.isArray(parsed.topics) ? parsed.topics.filter(t => t && t.title).slice(0, 6) : []
return { ok: true, topics, multi: topics.length > 1 }
}
// Crée 1 ticket par sujet. La conversation RESTE (lien vers tous les tickets via linkedTickets). Notifie la file de chaque sujet.
async function splitConversation (token, { topics } = {}) {
const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' }
let list = Array.isArray(topics) && topics.length ? topics : null
if (!list) { const d = await detectSplitTopics(token); if (!d.ok) return d; list = d.topics }
if (!list.length) return { ok: false, error: 'Aucun sujet détecté' }
const tickets = []
for (const t of list) {
const r = await createTicketFromConversation(token, { title: t.title, category: t.category }).catch(e => ({ ok: false, error: e.message }))
if (r && r.ok) {
tickets.push({ issue: r.issue, subject: r.subject, queue: t.queue || '' })
if (t.queue && QUEUES.includes(t.queue)) { try { await notifyQueueLabel(t.queue, conv, r.subject) } catch (e) { /* */ } } // chaque équipe notifiée de SON ticket
}
}
if (tickets.length) { addMessage(conv, { from: 'system', text: `✂️ Conversation scindée en ${tickets.length} ticket(s) : ${tickets.map(x => x.issue).join(', ')}` }); saveToDisk() }
return { ok: tickets.length > 0, tickets, count: tickets.length }
}
// ── MERGE : fusionne la conversation SOURCE dans la conversation CIBLE (doublons) ──
// Les messages/notes/tickets de la source sont rapatriés dans la cible ; la source est marquée fusionnée (masquée).
function mergeConversations (targetToken, sourceToken) {
if (targetToken === sourceToken) return { ok: false, error: 'Identiques' }
const target = getConversation(targetToken); const source = getConversation(sourceToken)
if (!target) return { ok: false, error: 'Cible introuvable' }
if (!source) return { ok: false, error: 'Source introuvable' }
const srcLabel = source.customerName || source.email || source.phone || source.token
target.messages = (target.messages || []).concat((source.messages || []).filter(m => m.from !== 'system'))
.sort((a, b) => String(a.ts).localeCompare(String(b.ts)))
if (source.notes && source.notes.length) target.notes = (target.notes || []).concat(source.notes)
if (source.linkedTickets && source.linkedTickets.length) target.linkedTickets = (target.linkedTickets || []).concat(source.linkedTickets)
if (!target.customer && source.customer) { target.customer = source.customer; target.customerName = target.customerName || source.customerName }
if (!target.email && source.email) target.email = source.email
if (!target.threadId && source.threadId) target.threadId = source.threadId
if (Array.isArray(source._emailMsgIds)) target._emailMsgIds = (target._emailMsgIds || []).concat(source._emailMsgIds)
addMessage(target, { from: 'system', text: `🔗 Conversation « ${srcLabel} » (#${source.token}) fusionnée ici` })
source.status = 'merged'; source.mergedInto = target.token; source.noise = true // disparaît de l'Inbox
saveToDisk()
sse.broadcast('conversations', 'conv-merged', { target: target.token, source: source.token })
log(`Merge ${source.token}${target.token}`)
return { ok: true, target: target.token, source: source.token, messages: target.messages.length }
}
// ── COMMANDE EN LANGAGE NATUREL (copilote) ──
// Gemini interprète la commande de l'agent + le contexte → exécute les actions SÛRES (file, claim, note, ticket)
// et RÉDIGE un brouillon de réponse (JAMAIS auto-envoyé — l'agent valide d'un clic). Réutilise gmail/createTicket/claim.
async function nlCommand (token, command, agentEmail) {
const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' }
if (!cfg.AI_API_KEY) return { ok: false, error: 'IA indisponible' }
if (!String(command || '').trim()) return { ok: false, error: 'commande vide' }
const msgs = (conv.messages || []).slice(-8).map(m => (m.from === 'customer' ? 'CLIENT' : (m.via === 'ai' ? 'IA' : 'AGENT')) + ': ' + String(m.text || '').slice(0, 300)).join('\n')
const ctx = `Conversation #${conv.token} · canal ${conv.channel || 'sms'} · client ${conv.customerName || '?'} (${conv.customer ? 'connu' : 'inconnu'}) · file actuelle ${conv.queue || '(aucune)'} · assigné ${conv.assignee || '(personne)'}\nDerniers messages :\n${msgs}`
const sys = 'Tu assistes un agent support TARGO/Gigafibre (fournisseur Internet). À partir de la COMMANDE de l\'agent et du CONTEXTE, renvoie UNIQUEMENT du JSON (aucun texte autour) : {"queue":"Facturations|Service à la clientèle|Supports|Technicien|null","claim":true|false,"note":"note interne en français ou null","reply":"brouillon de réponse au CLIENT en français, ton professionnel, ou null","create_ticket":{"title":"...","category":"Support|Facturation|Vente|Installation|Commercial|Autre","priority":"Low|Medium|High|Urgent"} ou null,"summary":"une phrase résumant ce que tu as fait"}. N\'invente jamais d\'information (montants, dates, identités). Mets "reply" SEULEMENT si la commande demande explicitement d\'écrire/répondre au client.'
let out
try {
out = await require('./ai').chat({ task: 'nl', maxTokens: 700, temperature: 0.2, messages: [{ role: 'system', content: sys }, { role: 'user', content: 'CONTEXTE :\n' + ctx + '\n\nCOMMANDE : ' + command }] })
} catch (e) { return { ok: false, error: 'IA : ' + e.message } }
let plan; try { plan = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0]) } catch (e) { return { ok: false, error: 'réponse IA illisible' } }
const applied = []
if (plan.queue && QUEUES.includes(plan.queue)) { conv.queue = plan.queue; applied.push('File → ' + plan.queue); sse.broadcast('conversations', 'conv-queue', { token, queue: conv.queue }) }
if (plan.claim) { conv.assignee = agentEmail || 'agent'; conv.assigneeAt = new Date().toISOString(); applied.push('Assigné à ' + (agentEmail || 'vous')); sse.broadcast('conversations', 'conv-claimed', { token, assignee: conv.assignee }) }
if (plan.note) { conv.notes = conv.notes || []; const note = { id: crypto.randomUUID(), agent: agentEmail || 'IA', text: String(plan.note), ts: new Date().toISOString() }; conv.notes.push(note); applied.push('Note interne ajoutée'); sse.broadcast('conversations', 'conv-note', { token, note }) }
let ticket = null
if (plan.create_ticket && plan.create_ticket.title) { const r = await createTicketFromConversation(token, { title: plan.create_ticket.title, category: plan.create_ticket.category, priority: plan.create_ticket.priority }).catch(e => ({ ok: false })); if (r && r.ok) { ticket = r.issue; applied.push('Ticket ' + r.issue + ' créé') } }
if (applied.length) { conv.lastHumanDate = todayET(); saveToDisk() }
return { ok: true, applied, draft_reply: plan.reply || null, summary: plan.summary || '', ticket }
}
const TK = /^\/conversations\/([A-Za-z0-9_-]{5,10})$/
const TK_SUB = /^\/conversations\/([A-Za-z0-9_-]{5,10})\//
// RÉSUMÉ IA d'un fil (style « Summarise this email » de Gmail) — pour l'agent, pas envoyé au client. PII → routé 'triage'.
async function summarizeConversation (token) {
const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' }
const who = m => m.from === 'agent' ? (m.via === 'ai' ? 'IA' : 'Agent') : (conv.customerName || conv.email || 'Client')
const msgs = (conv.messages || []).map(m => {
const body = String(m.text || '').replace(/^✉️[^\n]*\n+/, '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 1200)
return body ? `${who(m)} (${String(m.ts || '').slice(0, 16).replace('T', ' ')}): ${body}` : ''
}).filter(Boolean).join('\n')
if (!msgs.trim()) return { ok: true, summary: '' }
const sys = 'Tu es l\'assistant d\'un fournisseur Internet (TARGO/Gigafibre). Résume ce fil pour un agent du service à la clientèle, en français, en 2 à 4 puces COURTES : le sujet, la demande/le problème, ce qui a déjà été fait, et la PROCHAINE action requise (s\'il y en a une). Factuel et bref. N\'invente rien. Réponds uniquement avec les puces (préfixe « • »).'
try {
// reasoning_effort:'none' désactive le « thinking » de gemini-2.5-flash (sinon ses jetons de réflexion mangent maxTokens et tronquent le résumé sur un long fil). Un résumé n'a pas besoin de chaîne de raisonnement → plus rapide + complet + ~200 jetons.
const out = await require('./ai').chat({ task: 'triage', maxTokens: 500, temperature: 0.2, reasoningEffort: 'none', messages: [{ role: 'system', content: sys }, { role: 'user', content: 'FIL :\n' + msgs.slice(0, 6000) }] })
return { ok: true, summary: String(out || '').trim() }
} catch (e) { log('summarizeConversation error: ' + e.message); return { ok: false, error: 'Résumé IA indisponible' } }
}
// #2 DISPONIBILITÉ DU SERVICE (réponse de vente) : l'IA extrait l'adresse, la fibre est vérifiée DE FAÇON DÉTERMINISTE dans la base RQA locale
// (JAMAIS inventée par l'IA → on ne promet jamais à tort un service disponible), puis on PRÉPARE un brouillon. N'ENVOIE RIEN (l'agent valide).
async function serviceabilityDraft (token, { address: providedAddr } = {}) {
const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' }
let address = String(providedAddr || '').trim()
if (!address) { // extraction IA (sans raisonnement) depuis les messages du CLIENT
const text = (conv.messages || []).filter(m => m.from === 'customer').map(m => String(m.text || '').replace(/^✉️[^\n]*\n+/, '').replace(/<[^>]+>/g, ' ')).join('\n').slice(0, 3000)
try {
const sys = 'Extrais UNIQUEMENT l\'adresse postale complète (numéro civique, rue, ville, code postal) mentionnée par le client dans un fil de courriels. Réponds en JSON STRICT : {"address":"..."} — ou {"address":""} si aucune adresse claire.'
const out = await require('./ai').chat({ task: 'triage', maxTokens: 150, temperature: 0, reasoningEffort: 'none', messages: [{ role: 'system', content: sys }, { role: 'user', content: text }] })
address = String((JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0]).address) || '').trim()
} catch (e) { log('serviceability extract: ' + e.message) }
}
if (!address) return { ok: true, needs_address: true, message: 'Aucune adresse détectée dans le fil — précisez-la.' }
let match = null
try { const rows = await require('./address-db').searchLocal(address, 1); match = rows && rows[0] } catch (e) { log('serviceability searchLocal: ' + e.message); return { ok: false, error: 'Recherche d\'adresse indisponible' } }
if (!match) return { ok: true, needs_address: true, address, message: 'Adresse introuvable dans la base RQA — vérifiez l\'orthographe.' }
const fiber = !!match.fiber_available
const adr = match.adresse_formatee || address
const name = conv.customerName && !/@/.test(conv.customerName) ? String(conv.customerName).split(' ')[0] : ''
const portal = cfg.CLIENT_PORTAL_URL || 'https://portal.gigafibre.ca'
const link = portal + '/?adresse=' + encodeURIComponent(adr)
const hi = name ? 'Bonjour ' + name + ',' : 'Bonjour,'
const draft = fiber
? hi + '\n\nMerci de votre intérêt envers Gigafibre. Bonne nouvelle : notre service de fibre optique est disponible à votre adresse (' + adr + ')' + (match.max_speed ? ', avec des vitesses pouvant atteindre ' + match.max_speed + '.' : '.') + '\n\nPour vous abonner en quelques minutes, cliquez ici : ' + link + '\n\nN\'hésitez pas si vous avez des questions.\n\nCordialement,\nL\'équipe Gigafibre'
: hi + '\n\nMerci de votre intérêt envers Gigafibre. Pour le moment, notre fibre optique n\'est pas encore disponible à votre adresse (' + adr + ').\n\nNous prenons note de votre intérêt et vous contacterons dès qu\'elle sera déployée dans votre secteur.\n\nCordialement,\nL\'équipe Gigafibre'
return { ok: true, address: adr, fiber_available: fiber, max_speed: match.max_speed || null, similarity: match.similarity_score, funnel_link: fiber ? link : null, draft_text: draft, draft_html: draft.replace(/\n/g, '<br>') }
}
async function handle (req, res, method, p, url) {
// INBOX UNIFIÉ — tickets (Issues ERPNext) ACTIFS RÉCENTS (file agent), fusionnés avec les conversations côté front.
// ⚠️ 897 Issues Open existent (backlog legacy importé, jamais retouché) → on filtre sur l'ACTIVITÉ RÉCENTE (modifié < N j)
// pour ne montrer que la vraie file (les tickets créés depuis conversations apparaissent ici). ?days= et ?all=1 (backlog) en option.
if (p === '/conversations/inbox-tickets' && method === 'GET') {
try {
const all = url && url.searchParams && url.searchParams.get('all') === '1'
const days = Math.max(1, Math.min(365, Number(url && url.searchParams && url.searchParams.get('days')) || 45))
const filters = [['status', 'in', ['Open', 'Replied']]]
if (!all) { const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); filters.push(['modified', '>', since]) }
const rows = await erp.list('Issue', { filters, fields: ['name', 'subject', 'customer', 'status', 'priority', 'modified', 'creation'], limit: 200, orderBy: 'modified desc' })
return json(res, 200, { tickets: rows || [], recent_days: all ? null : days })
} catch (e) { log('inbox-tickets error:', e.message); return json(res, 200, { tickets: [], error: e.message }) }
}
// TRIAGE IA (Phase 3) — aperçu : classe un courriel (client connu ? type ? suggérer ticket ?). Réutilisé par le poller mail.
if (p === '/conversations/triage-preview' && method === 'POST') {
try { const b = await parseBody(req); const r = await require('./inbox-triage').classifyEmail(b || {}); return json(res, 200, r) } catch (e) { return json(res, 500, { error: e.message }) }
}
// PIPELINE de leads : board (colonnes par étape) des conversations en pipeline.
if (p === '/conversations/pipeline-board' && method === 'GET') {
const cols = {}; PIPELINE_STAGES.forEach(s => { cols[s] = [] })
for (const conv of conversations.values()) {
if (!conv.pipeline || !conv.pipeline.stage) continue
if (new Date(conv.expiresAt) < new Date()) continue
const st = PIPELINE_STAGES.includes(conv.pipeline.stage) ? conv.pipeline.stage : 'Nouveau'
cols[st].push({ token: conv.token, customerName: conv.customerName, contact: conv.email || conv.phone || '', subject: conv.subject || '', value: conv.pipeline.value || 0, queue: conv.queue || '', assignee: conv.assignee || null, channel: conv.channel || 'sms', is_customer: !!(conv.triage && conv.triage.is_customer), lastActivity: conv.lastActivity })
}
for (const s in cols) cols[s].sort((a, b) => String(b.lastActivity).localeCompare(String(a.lastActivity)))
return json(res, 200, { stages: PIPELINE_STAGES, columns: cols })
}
// Appartenance agent ↔ file (config éditable). GET = lire ; POST {members:{...}} = écrire.
if (p === '/conversations/queue-members' && (method === 'GET' || method === 'POST')) {
if (method === 'GET') return json(res, 200, { members: loadQueueMembers(), queues: QUEUES, labels: loadQueueLabels() })
try {
const b = await parseBody(req)
if (b.members) writeJsonFile('/app/data/queue_members.json', b.members)
if (b.labels) writeJsonFile('/app/data/queue_labels.json', b.labels) // libellés d'affichage (nom réel = clé inchangée)
return json(res, 200, { ok: true, members: b.members || loadQueueMembers(), labels: b.labels || loadQueueLabels() })
} catch (e) { return json(res, 500, { error: e.message }) }
}
// Règles d'inbox (« Filtrer comme ceci ») : masquer/afficher par expéditeur ou sujet — l'agent choisit ce qui est masqué.
if (p === '/conversations/inbox-rules' && (method === 'GET' || method === 'POST')) {
if (method === 'GET') return json(res, 200, { rules: loadInboxRules() })
try {
const b = await parseBody(req)
writeJsonFile('/app/data/inbox_rules.json', Array.isArray(b.rules) ? b.rules : [])
// Rétro-application aux conversations EXISTANTES : masquer/afficher tout de suite ce qui correspond (sinon les règles n'agiraient qu'au prochain courriel).
let changed = 0
for (const c of conversations.values()) {
if (c.channel !== 'email') continue
const r = matchInboxRule(c.email, c.lastSubject || c.subject)
if (r === 'mask' && !c.noise) { c.noise = true; changed++ } else if (r === 'allow' && c.noise) { c.noise = false; changed++ }
}
if (changed) saveToDisk()
return json(res, 200, { ok: true, rules: loadInboxRules(), retro: changed })
} catch (e) { return json(res, 500, { error: e.message }) }
}
// Réponses pré-écrites (canned) : liste + sauvegarde (remplacement complet du tableau, comme queue-members).
if (p === '/conversations/canned' && (method === 'GET' || method === 'POST')) {
if (method === 'GET') return json(res, 200, { responses: loadCanned() })
try { const b = await parseBody(req); const list = Array.isArray(b) ? b : (b.responses || []); const ok = saveCanned(list); return json(res, ok ? 200 : 500, { ok, responses: list }) } catch (e) { return json(res, 500, { error: e.message }) }
}
// NOUVEAU courriel SORTANT depuis l'Inbox (agent). Gaté (Authentik/proxy).
if (p === '/conversations/email-new' && method === 'POST') {
try { const b = await parseBody(req); const r = await sendNewEmail({ ...(b || {}), agentEmail: req.headers['x-authentik-email'] || (b && b.agentEmail) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
}
// INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik).
if (p === '/conversations/email-ingest' && method === 'POST') {
const fs = require('fs')
const tok = req.headers['x-mail-token'] || ''
let want = (process.env.MAIL_INGEST_TOKEN || '').trim()
if (!want) { try { want = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) {} }
if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' })
try { const b = await parseBody(req); const r = await ingestEmail(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) }
}
// INGESTION CANAL (web chat nouveau site / 3CX / Facebook) — webhook externe. Protégé par X-Mail-Token (même jeton que l'ingest courriel).
if (p === '/conversations/channel-ingest' && method === 'POST') {
const fs = require('fs')
const tok = req.headers['x-mail-token'] || ''
let want = (process.env.MAIL_INGEST_TOKEN || '').trim()
if (!want) { try { want = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) {} }
if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' })
try { const b = await parseBody(req); const r = await channelIngest(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/conversations' && method === 'POST') {
const body = await parseBody(req)
const { phone, customer, customerName, subject, message, media } = body
if (!phone) return json(res, 400, { error: 'Missing phone' })
const agentEmail = req.headers['x-authentik-email'] || null
const conv = createConversation({ phone, customer, customerName, agentEmail, subject: subject || (message ? String(message).slice(0, 60) : '') })
conv.lastHumanDate = todayET() // fil INITIÉ depuis notre interface (humain) → l'IA ne répond pas le même jour
const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
const { sendSmsInternal } = require('./twilio')
let outText
if (message || media) {
// Texto chat : on envoie LE TEXTE DE L'AGENT (+ image MMS), pas un gabarit de lien.
outText = String(message || '')
addMessage(conv, { from: 'agent', text: outText, via: 'sms', type: media ? 'image' : 'text', media: media || '' })
try { await sendSmsInternal(phone, outText, customer, media || ''); conv.smsCount++; saveToDisk() } catch (e) { log('SMS/MMS send failed:', e.message) }
} else {
outText = subject ? `Gigafibre — ${subject}\n\nContinuez la conversation ici:\n${link}` : `Gigafibre — Vous avez un nouveau message.\n\nRépondre ici:\n${link}`
try { await sendSmsInternal(phone, outText, customer); conv.smsCount++; saveToDisk() } catch (e) { log('Failed to send link SMS:', e.message) }
}
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: outText + (media ? '\n[image jointe]' : ''), subject: 'SMS sortant',
reference_doctype: 'Customer', reference_name: customer, status: 'Linked',
}).catch(() => {})
}
return json(res, 201, { ok: true, token: conv.token, link })
}
if (p === '/conversations' && method === 'GET')
return json(res, 200, listConversations({ includeAll: url.searchParams.get('all') === '1' }))
// GET /conversations/leaderboard?from=YYYY-MM-DD&to=YYYY-MM-DD — classement des agents Inbox (reconnaissance / motivation).
// « pris » = conversations dont l'agent est assigné (claim, via assigneeAt) ; « réponses » = messages agent horodatés à son nom (stamping msg.agent — historique avant stamping = unattributed).
if (p === '/conversations/leaderboard' && method === 'GET') {
const to = url.searchParams.get('to') || new Date().toISOString().slice(0, 10)
const from = url.searchParams.get('from') || new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10)
const inRange = (iso) => { const d = String(iso || '').slice(0, 10); return !!d && d >= from && d <= to }
const agents = {}
const A = (k) => (agents[k] = agents[k] || { agent: k, handled: 0, replies: 0 })
let unattributed = 0
for (const [, conv] of conversations) {
if (conv.assignee && (!conv.assigneeAt || inRange(conv.assigneeAt))) A(conv.assignee).handled++
for (const m of (conv.messages || [])) {
if (m.from !== 'agent' || !inRange(m.ts)) continue
if (m.agent) A(m.agent).replies++; else unattributed++
}
}
const board = Object.values(agents).sort((a, b) => (b.handled - a.handled) || (b.replies - a.replies)).slice(0, 20)
return json(res, 200, { from, to, board, unattributed })
}
// POST /conversations/backfill-senders — rétro-remplit msg.fromName/fromEmail des courriels
// ingérés AVANT la capture per-message de l'en-tête From, en relisant Gmail. En-process
// (mute l'état mémoire + saveToDisk → pas de course de fichier). Idempotent : ne touche
// que les messages courriel SANS fromName. Mappe par msg.gmail_id, sinon par ordre via _gmailIds.
if (p === '/conversations/backfill-senders' && method === 'POST') {
const gmail = require('./gmail')
const { extractEmail } = require('./inbox-triage')
let patched = 0, convsTouched = 0
for (const [, conv] of conversations) {
if (conv.channel !== 'email') continue
const gids = conv._gmailIds || []
const emailMsgs = (conv.messages || []).filter(m => m.via === 'email' || m.html)
let touched = false
for (let i = 0; i < emailMsgs.length; i++) {
const m = emailMsgs[i]
if (m.fromName) continue
const gid = m.gmail_id || gids[i]
if (!gid) continue
try {
const gm = await gmail.getMessage(gid)
const nm = gm && gm.from ? fromDisplayName(gm.from) : ''
if (nm) { m.fromName = nm; m.fromEmail = extractEmail(gm.from); m.gmail_id = gid; patched++; touched = true }
} catch (e) { /* message Gmail introuvable → ignorer */ }
}
if (touched) convsTouched++
}
saveToDisk()
return json(res, 200, { ok: true, patched, convsTouched })
}
// Cherche les fiches Customer par téléphone → l'OPS propose de lier / de choisir parmi plusieurs.
if (p === '/conversations/resolve' && method === 'GET') {
const emailQ = url.searchParams.get('email')
const matches = emailQ ? await lookupCustomersByEmail(emailQ, 8) : await lookupCustomersByPhone(url.searchParams.get('phone') || '', 8)
return json(res, 200, { ok: true, count: matches.length, matches })
}
if (p === '/conversations/archive' && method === 'POST') {
const { tokens } = await parseBody(req)
if (!tokens || !tokens.length) return json(res, 400, { error: 'Missing tokens array' })
try {
const result = await archiveDiscussion(tokens)
if (!result) return json(res, 404, { error: 'No messages found' })
sse.broadcast('conversations', 'conv-deleted', { archived: true, issue: result.issue })
return json(res, 200, { ok: true, ...result })
} catch (e) { return json(res, 500, { error: 'Archive failed: ' + e.message }) }
}
if (p === '/conversations/bulk' && method === 'DELETE') {
const { discussions: discs } = await parseBody(req)
if (!discs || !discs.length) return json(res, 400, { error: 'Missing discussions array' })
let totalDeleted = 0
for (const d of discs) {
totalDeleted += d.tokens ? deleteDiscussionByTokens(d.tokens) : (d.phone && d.date ? deleteDiscussion(d.phone, d.date) : 0)
}
sse.broadcast('conversations', 'conv-deleted', { bulk: true, count: totalDeleted })
return json(res, 200, { ok: true, deleted: totalDeleted })
}
if (p === '/conversations/discussion' && method === 'DELETE') {
const { phone, date } = await parseBody(req)
if (!phone || !date) return json(res, 400, { error: 'Missing phone or date' })
const count = deleteDiscussion(phone, date)
log(`Deleted discussion: ${phone} on ${date} (${count} conversations)`)
sse.broadcast('conversations', 'conv-deleted', { phone, date, count })
return json(res, 200, { ok: true, deleted: count })
}
// Liste des agents assignables (roster DÉCOUPLÉ des notifs de file). AVANT le match TK (« agents » ≈ un token).
// Source : /app/data/agents.json (roster canonique) ; repli = union des membres de file. Découplé car les listes
// de notif peuvent être vidées (anti-spam) sans retirer l'agent du choix d'assignation.
if (p === '/conversations/agents' && method === 'GET') {
let list = readJsonFile('/app/data/agents.json', null)
if (!Array.isArray(list) || !list.length) { const mem = loadQueueMembers(); const set = new Set(); for (const k of Object.keys(mem)) for (const e of (mem[k] || [])) set.add(e); list = [...set] }
const out = [...new Set((list || []).map(e => String(e).toLowerCase()).filter(e => /.+@.+\..+/.test(e)))].sort()
return json(res, 200, { agents: out })
}
let m
if ((m = p.match(TK)) && method === 'GET') {
const conv = getConversation(m[1])
if (!conv) return json(res, 404, { error: 'Conversation not found or expired' })
return json(res, 200, {
token: conv.token, customerName: conv.customerName, subject: conv.subject,
status: conv.status, messages: conv.messages, createdAt: conv.createdAt,
customer: conv.customer || null, phone: conv.phone || null,
channel: conv.channel || 'sms', email: conv.email || null,
queue: conv.queue || '', assignee: conv.assignee || null, assigneeAt: conv.assigneeAt || null,
assistants: conv.assistants || [], ticketState: conv.ticketState || (conv.status === 'closed' ? 'closed' : 'open'), pendingUntil: conv.pendingUntil || null,
notes: conv.notes || [], linkedTickets: conv.linkedTickets || [], suggestedTicket: conv.suggestedTicket || null,
pipeline: conv.pipeline || null, pipelineStages: PIPELINE_STAGES,
triage: conv.triage || null, gmailReplyUrl: gmailReplyUrl(conv), queues: QUEUES,
readBy: conv.readBy || {}, draft: conv.draft || null,
vapidPublicKey: cfg.VAPID_PUBLIC_KEY || null,
})
}
if ((m = p.match(TK)) && method === 'DELETE') {
const existed = deleteConversation(m[1], { trashGmail: true }) // suppression explicite → courriels liés à la corbeille Gmail (réversible 30 j)
if (!existed) return json(res, 404, { error: 'Conversation not found' })
log(`Deleted conversation: ${m[1]}`)
sse.broadcast('conversations', 'conv-deleted', { token: m[1] })
return json(res, 200, { ok: true })
}
if ((m = p.match(TK_SUB))) {
const sub = p.slice(p.indexOf(m[1]) + m[1].length + 1)
const token = m[1]
// Créer un TICKET (ERPNext Issue OUVERT) depuis la conversation — la conversation RESTE (≠ archive).
if (sub === 'ticket' && method === 'POST') {
const b = await parseBody(req)
const r = await createTicketFromConversation(token, b)
return json(res, r.ok ? 200 : 500, r)
}
// SPLIT — détecte les sujets distincts (aperçu, ne crée rien) puis crée 1 ticket par sujet sur confirmation.
if (sub === 'split-preview' && method === 'GET') {
const r = await detectSplitTopics(token)
return json(res, r.ok ? 200 : 400, r)
}
if (sub === 'split' && method === 'POST') {
const b = await parseBody(req)
const r = await splitConversation(token, { topics: b.topics })
return json(res, r.ok ? 200 : 400, r)
}
// MERGE — fusionne une conversation SOURCE (body.from) dans CELLE-CI (cible = token de l'URL).
if (sub === 'merge' && method === 'POST') {
const b = await parseBody(req)
const r = mergeConversations(token, String(b.from || b.source || ''))
return json(res, r.ok ? 200 : 400, r)
}
// PREUVE DE PAIEMENT — OCR d'une image (capture client) → affiche montant/date/réf (sans écrire en facturation).
if (sub === 'payment-proof' && method === 'POST') {
const b = await parseBody(req)
const r = await analyzePaymentProof(token, b)
return json(res, r.ok ? 200 : 400, r)
}
// RÉSUMÉ IA du fil (Gemini) — « Summarise this email » de Gmail, pour l'agent.
if (sub === 'summarize' && (method === 'POST' || method === 'GET')) {
const r = await summarizeConversation(token)
return json(res, r.ok ? 200 : 400, r)
}
// #2 DISPONIBILITÉ : adresse (IA) → fibre (RQA) → brouillon de réponse de vente (l'agent valide avant envoi).
if (sub === 'serviceability' && method === 'POST') {
const b = await parseBody(req)
const r = await serviceabilityDraft(token, b)
return json(res, r.ok ? 200 : 400, r)
}
// ── Coordination shared-inbox : claim / release / file / note interne ──
if (sub === 'claim' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const agent = req.headers['x-authentik-email'] || ((await parseBody(req)).agent) || 'agent'
conv.assignee = agent; conv.assigneeAt = new Date().toISOString(); conv.lastHumanDate = todayET(); saveToDisk()
sse.broadcast('conversations', 'conv-claimed', { token, assignee: agent }) // collision : les autres voient « pris par X » en direct
return json(res, 200, { ok: true, assignee: agent })
}
if (sub === 'release' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
conv.assignee = null; conv.assigneeAt = null; saveToDisk()
sse.broadcast('conversations', 'conv-claimed', { token, assignee: null })
return json(res, 200, { ok: true })
}
// Assignation DIRECTE (agent précis) + ASSISTANTS (liste d'emails, affichés en chips) — modèle assign_to + participants de F.
if (sub === 'assign' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req)
if (b.assignee !== undefined) { conv.assignee = b.assignee ? String(b.assignee).toLowerCase() : null; conv.assigneeAt = conv.assignee ? new Date().toISOString() : null }
if (Array.isArray(b.assistants)) conv.assistants = [...new Set(b.assistants.map(e => String(e).toLowerCase()).filter(e => /.+@.+\..+/.test(e) && e !== conv.assignee))]
conv.lastHumanDate = todayET(); saveToDisk()
sse.broadcast('conversations', 'conv-claimed', { token, assignee: conv.assignee, assistants: conv.assistants || [] })
return json(res, 200, { ok: true, assignee: conv.assignee, assistants: conv.assistants || [] })
}
// ÉTAT « ticket » : open | pending (avec date d'attente) | closed. Reflété sur le ticket lié (Open/On Hold/Closed + rappel) s'il existe.
if (sub === 'state' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req)
const st = ['open', 'pending', 'closed'].includes(b.state) ? b.state : 'open'
conv.ticketState = st
conv.pendingUntil = (st === 'pending' && b.pendingUntil) ? String(b.pendingUntil).slice(0, 10) : null
conv.status = (st === 'closed') ? 'closed' : 'active' // fermé = quitte la file active ; ouvert/attente = actif
conv.lastHumanDate = todayET(); saveToDisk()
if (conv.linkedTickets && conv.linkedTickets.length) { // reflète sur le ticket ERPNext lié
const issue = conv.linkedTickets[0].name
const data = { status: st === 'closed' ? 'Closed' : st === 'pending' ? 'On Hold' : 'Open' }
if (st === 'pending' && conv.pendingUntil) data.custom_reminder_date = conv.pendingUntil
try { await require('./erp').update('Issue', issue, data) } catch (e) { log('state→ticket ' + issue + ': ' + e.message) }
}
sse.broadcast('conversations', 'conv-state', { token, ticketState: st, pendingUntil: conv.pendingUntil, status: conv.status })
return json(res, 200, { ok: true, ticketState: st, pendingUntil: conv.pendingUntil, status: conv.status })
}
// SPAM → marque le(s) courriel(s) SPAM côté Gmail (Gmail apprend + sort de la boîte) puis retire la conversation de l'Inbox.
if (sub === 'spam' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
let marked = 0
if (conv.channel === 'email') {
const gmail = require('./gmail')
const ids = (conv._gmailIds && conv._gmailIds.length) ? conv._gmailIds : (conv.lastGmailId ? [conv.lastGmailId] : [])
for (const id of ids) { try { await gmail.markSpam(id); marked++ } catch (e) { log('markSpam ' + id + ': ' + e.message) } }
}
conversations.delete(token); saveToDisk() // SPAM côté Gmail = le dossier Spam EST l'archive ; on ne met PAS à la corbeille
sse.broadcast('conversations', 'conv-deleted', { token, spam: true })
log(`Conv ${token} → SPAM (${marked} courriel(s) marqués) + retirée de l'Inbox`)
return json(res, 200, { ok: true, spammed: marked })
}
if (sub === 'queue' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req); conv.queue = String(b.queue || ''); saveToDisk()
sse.broadcast('conversations', 'conv-queue', { token, queue: conv.queue })
if (conv.queue && !conv.assignee) notifyQueue(conv).catch(() => {}) // routage manuel → pousse aux membres de la file
return json(res, 200, { ok: true, queue: conv.queue })
}
if (sub === 'note' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req); const text = String(b.text || '').trim(); if (!text) return json(res, 400, { error: 'text requis' })
const agent = req.headers['x-authentik-email'] || 'agent'
conv.notes = conv.notes || []; const note = { id: crypto.randomUUID(), agent, text, ts: new Date().toISOString() }
conv.notes.push(note); conv.lastHumanDate = todayET(); saveToDisk()
sse.broadcast('conversations', 'conv-note', { token, note }) // note INTERNE (jamais envoyée au client) → remplace le reply-all « je m'en occupe »
return json(res, 200, { ok: true, note })
}
if (sub === 'nl' && method === 'POST') { // commande en langage naturel (copilote)
const b = await parseBody(req)
const r = await nlCommand(token, b.command || '', req.headers['x-authentik-email'] || '')
return json(res, r.ok ? 200 : 400, r)
}
if (sub === 'pipeline' && method === 'POST') { // entrer/déplacer/retirer un lead dans le pipeline
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req)
conv.pipeline = conv.pipeline || {}
if (b.stage != null) conv.pipeline.stage = String(b.stage)
if (b.value != null) conv.pipeline.value = Number(b.value) || 0
conv.pipeline.updatedAt = new Date().toISOString()
if (!conv.pipeline.stage) conv.pipeline = null // étape vide = retiré du pipeline
saveToDisk()
sse.broadcast('conversations', 'conv-pipeline', { token, pipeline: conv.pipeline })
return json(res, 200, { ok: true, pipeline: conv.pipeline })
}
// Rattacher la conversation à une fiche client choisie (résout le cas multi-fiches).
if (sub === 'link' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req); if (!b.customer) return json(res, 400, { error: 'customer requis' })
conv.customer = String(b.customer); conv.customerName = b.customerName || String(b.customer); conv.candidates = null
// Compte lié → on suggère de PROMOUVOIR le fil en ticket (le bouton apparaît dans l'Inbox ; clic = import du fil + réponses [Ticket #N]).
if (!conv.suggestedTicket && !(conv.linkedTickets && conv.linkedTickets.length)) {
const lastCust = [...(conv.messages || [])].reverse().find(m => m.from === 'customer')
conv.suggestedTicket = { title: conv.subject || (lastCust && String(lastCust.text || '').replace(/^✉️[^\n]*\n+/, '').slice(0, 90)) || 'Demande client', category: 'Support', priority: 'Medium', reason: 'Compte client lié — promouvoir le fil en ticket', ts: new Date().toISOString() }
}
saveToDisk()
sse.broadcast('conversations', 'conv-update', { token: conv.token, customer: conv.customer, customerName: conv.customerName })
return json(res, 200, { ok: true, token: conv.token, customer: conv.customer, customerName: conv.customerName })
}
// Présence agent : signale que CET agent (Authentik) est en train d'écrire → visible par les AUTRES agents OPS.
if (sub === 'typing' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const agentEmail = req.headers['x-authentik-email'] || null
const sid = (url && url.searchParams.get('sid')) || null // identifiant d'onglet (filtre côté client)
if (agentEmail) {
const payload = { token: conv.token, agent: agentEmail, sid, customer: conv.customer, customerName: conv.customerName }
sse.broadcast('conv:' + conv.token, 'conv-agent-typing', payload)
sse.broadcast('conversations', 'conv-agent-typing', payload)
}
return json(res, 200, { ok: true })
}
// « Vu par » : marque que CET agent a ouvert/lu la conversation → avatars chez les autres agents.
if (sub === 'read' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const agentEmail = req.headers['x-authentik-email'] || null
if (agentEmail) {
const lastTs = conv.messages.length ? conv.messages[conv.messages.length - 1].ts : null
conv.readBy = conv.readBy || {}
conv.readBy[agentEmail] = { readAt: new Date().toISOString(), lastTs }
saveToDisk()
const payload = { token: conv.token, agent: agentEmail, readAt: conv.readBy[agentEmail].readAt, lastTs }
sse.broadcast('conv:' + conv.token, 'conv-read', payload)
sse.broadcast('conversations', 'conv-read', payload)
}
return json(res, 200, { ok: true, readBy: conv.readBy || {} })
}
// Brouillon partagé (miroir temps réel) : un agent rédige, les autres voient le texte en direct.
// Dernier qui écrit gagne (pas de co-édition simultanée). Vidé quand html/text sont vides (envoi/annulation).
if (sub === 'draft' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const agentEmail = req.headers['x-authentik-email'] || null
const sid = (url && url.searchParams.get('sid')) || null
const body = await parseBody(req)
const html = String(body.html || '')
const text = String(body.text || '')
conv.draft = (html.trim() || text.trim()) ? { agent: agentEmail, sid, html, text, ts: new Date().toISOString() } : null
saveToDisk()
const payload = { token: conv.token, agent: agentEmail, sid, draft: conv.draft }
sse.broadcast('conv:' + conv.token, 'conv-draft', payload)
sse.broadcast('conversations', 'conv-draft', payload)
return json(res, 200, { ok: true })
}
if (sub === 'messages' && method === 'POST') {
const conv = getConversation(token)
if (!conv) return json(res, 404, { error: 'Conversation not found or expired' })
const body = await parseBody(req)
const text = (body.text || '').trim()
const media = body.media || ''
const html = body.html || '' // réponse WYSIWYG (canal email)
if (!text && !media && !html) return json(res, 400, { error: 'Missing text or media' })
const agentEmail = req.headers['x-authentik-email']
const from = agentEmail ? 'agent' : 'customer'
const isEmail = conv.channel === 'email'
if (from === 'agent') conv.lastHumanDate = todayET() // humain (agent OPS) actif aujourd'hui → l'IA se tait ce jour (posé AVANT addMessage pour être persisté par son saveToDisk)
const msg = addMessage(conv, { from, text, via: isEmail ? 'email' : 'web', type: media ? 'image' : 'text', media, html, agent: agentEmail || '' })
if (from === 'agent') msg.notifiedVia = await notifyCustomer(conv, msg) // email → envoi HTML dans le fil ; sinon push/SMS
if (from === 'customer' && conv.customer && !isEmail) triggerAgent(conv) // pas d'auto-réponse IA sur courriel
if (isEmail && conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, msg, conv.lastSubject).catch(() => {}) // chaîne : alimente le ticket lié
if (conv.customer) {
createCommunication({
communication_type: 'Communication', communication_medium: isEmail ? 'Email' : 'Chat',
sent_or_received: from === 'agent' ? 'Sent' : 'Received',
sender: from === 'agent' ? (agentEmail || (isEmail ? (cfg.MAIL_FROM || 'cc@targointernet.com') : 'sms@gigafibre.ca')) : (isEmail ? (conv.email || '') : conv.phone),
sender_full_name: from === 'agent' ? 'Targo Ops' : conv.customerName,
phone_no: conv.phone, content: (html || text || '') + (media ? '\n[image jointe]' : ''), subject: isEmail ? (conv.lastSubject || conv.subject) : `Chat: ${conv.subject}`,
reference_doctype: 'Customer', reference_name: conv.customer, status: 'Linked',
}).catch(() => {})
}
return json(res, 201, { ok: true, message: msg })
}
if (sub === 'push' && method === 'POST') {
const body = await parseBody(req)
if (!body.endpoint || !body.keys) return json(res, 400, { error: 'Invalid push subscription' })
const ok = registerPush(token, body)
return json(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'Conversation not found' })
}
if (sub === 'close' && method === 'POST') {
const conv = getConversation(token)
if (!conv) return json(res, 404, { error: 'Conversation not found' })
conv.status = 'closed'
addMessage(conv, { from: 'system', text: 'Conversation terminée', via: 'web' })
saveToDisk()
sse.broadcast('conversations', 'conv-closed', { token: conv.token })
return json(res, 200, { ok: true })
}
if (sub === 'sse' && method === 'GET') {
const conv = getConversation(token)
if (!conv) return json(res, 404, { error: 'Conversation not found' })
res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache',
Connection: 'keep-alive', 'X-Accel-Buffering': 'no',
})
res.write(': connected\n\n')
sse.addClient(['conv-client:' + conv.token], res, 'customer:' + conv.phone)
const keepalive = setInterval(() => { try { res.write(': ping\n\n') } catch { clearInterval(keepalive) } }, 25000)
res.on('close', () => clearInterval(keepalive))
return
}
}
return json(res, 404, { error: 'Not found' })
}
module.exports = {
handle,
createTicketFromConversation,
ingestEmail,
channelIngest,
sendNewEmail,
sendSms,
nlCommand,
detectSplitTopics,
splitConversation,
mergeConversations,
analyzePaymentProof,
loadCanned,
saveCanned,
listConversations,
createConversation,
getConversation,
addMessage,
findConversationByPhone,
autoLinkIncomingSms,
triggerAgent,
notifyCustomer,
deleteConversation,
deleteDiscussion,
archiveDiscussion,
}