gigafibre-fsm/services/targo-hub/lib/conversation.js
louispaulb 788d969090 chore(reconcile): commit MES hunks déployés dans les fichiers co-édités (split via git apply --cached)
Réconciliation de l'audit : isole et commite UNIQUEMENT mon travail déployé-non-commité dans 7 fichiers co-édités,
sans toucher au working tree (donc sans capturer le travail en cours des autres sessions, qui reste non commité) :
- server.js : SSE event 'ready' + retry:3000 ; routes /olt/onu/plan|run + /olt/wifi-clients.
- conversation.js : endpoint /conversations/my-inbox-counts (accueil : en attente / suivis / en retard).
- useConversations.js : armSSE (reconnexion+resync+repli poll 25s) + cleanup.
- PlanificationPage.vue : chips géofence (Lane 1c) + sélecteur « Générer l'horaire » (Move 3) + isLate (arrivée en retard).
- SettingsPage.vue : section « Thème & couleurs » (ThemeEditor).
- IssueDetail.vue + ConversationPanel.vue : props source-issue/customer/service-location/phone (« Proposer au client »).
EXCLUS (restent non commités, propriété d'autres sessions) : /service-location, /conversations/msg-visibility, retrait
champ Mapbox, fix conv-message 'belongs', hiérarchie parent_incident, JobMediaModule, etc. Vérifié : 0 marqueur étranger
dans le staged. Tout est déjà LIVE ; ceci n'aligne que le dépôt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:06:47 -04:00

2128 lines
149 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 store = require('./store-pg') // magasin Postgres DURABLE (miroir best-effort + repli JSON si indispo)
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()
// Jeton d'ingestion webhook (courriel/canal) : env sinon fichier — lu UNE seule fois (mémoïsé) au lieu d'un
// fs.readFileSync SYNCHRONE à CHAQUE webhook entrant (bloquait la boucle d'événements). Redémarrage requis pour une rotation.
let _mailIngestToken
function mailIngestToken () {
if (_mailIngestToken === undefined) {
_mailIngestToken = (process.env.MAIL_INGEST_TOKEN || '').trim()
if (!_mailIngestToken) { try { _mailIngestToken = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) { _mailIngestToken = '' } }
}
return _mailIngestToken
}
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) }
}
// Les conversations COURRIEL sont DURABLES (comme des tickets) → jamais expirées ; SMS/web-chat gardent l'expiration 30 j (lien éphémère).
function isExpired (conv) { return !!conv && conv.channel !== 'email' && new Date(conv.expiresAt) < new Date() }
// Synchro Postgres DIRTY-ONLY (durable) : au plus toutes les 3 s, n'upsert QUE les conversations dont la
// sérialisation a changé depuis la dernière synchro (détection automatique, sans toucher aux ~40 appels saveToDisk).
const _syncedJson = new Map() // token → dernière sérialisation persistée en base
let _pgTimer = null
function syncPg () {
if (!store.available() || _pgTimer) return
_pgTimer = setTimeout(async () => {
_pgTimer = null
try {
for (const c of conversations.values()) {
const j = JSON.stringify(c)
if (_syncedJson.get(c.token) === j) continue
await store.upsert(c) // entête + messages (normalisé), transaction
_syncedJson.set(c.token, j) // marqué synchronisé SEULEMENT après upsert réussi
}
} catch (e) { /* best-effort ; ré-essai au prochain tick */ }
}, 3000)
}
let saveTimer = null
function saveToDisk () {
syncPg() // miroir Postgres durable (best-effort, throttlé) — EN PLUS du JSON (repli / rollback instantané)
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()
// Démarrage Postgres — MAÎTRE (système de record, comme les tickets) :
// 1) crée / migre le schéma normalisé `comms` (conversations + messages) ;
// 2) Postgres NON VIDE → il fait foi : on (re)charge la Map depuis Postgres ;
// 3) Postgres VIDE (1er boot / juste après migration) → on AMORCE depuis le JSON ;
// 4) Postgres indisponible → repli total sur le JSON (le hub reste 100 % fonctionnel).
// Le JSON reste écrit en double (sauvegarde / rollback). _syncedJson est amorcé pour ne pas tout ré-upserter au 1er changement.
;(async () => {
try {
if (!(await store.init())) { log('comms-pg: indisponible → lecture sur le JSON'); return }
const rows = await store.loadAll()
if (rows && rows.length) {
conversations.clear()
for (const c of rows) if (c && c.token) conversations.set(c.token, c)
log(`comms-pg: ${conversations.size} conversations chargées depuis Postgres (MAÎTRE) — ${await store.msgCount()} messages`)
} else if (conversations.size) {
log(`comms-pg: base vide → amorçage de ${conversations.size} conversations depuis le JSON…`)
const done = await store.upsertAll([...conversations.values()])
log(`comms-pg: amorçage terminé — ${done} conversations + ${await store.msgCount()} messages en base`)
}
for (const c of conversations.values()) _syncedJson.set(c.token, JSON.stringify(c)) // évite de tout ré-upserter au 1er changement
} catch (e) { log('comms-pg bootstrap:', e.message) }
})()
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
}
// Crée une conversation COURRIEL avec un brouillon pré-rempli (ex. récompense cadeau) à éditer/envoyer
// depuis la messagerie centralisée. Le corps contient déjà NOTRE lien /g/ (jamais le lien Giftbit brut).
function createDraftConversation ({ customer, customerName, email, subject, html, text, agentEmail }) {
const conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || email || 'Client', agentEmail: agentEmail || null, subject: subject || 'Courriel' })
conv.channel = 'email'
conv.email = email
conv.initiatedBy = 'agent'
conv.draft = (html || text) ? { agent: agentEmail || 'system', sid: 'reward', html: html || '', text: text || '', ts: new Date().toISOString() } : null
saveToDisk()
return conv
}
// P1 (convergence messages) — lie UN ticket osTicket legacy à UNE conversation comms, find-or-create IDEMPOTENT par legacy_ticket_id.
// Réutilise conv.linkedTickets (déjà la clé conv↔ticket pour les Issue ERPNext) avec une entrée { legacy_ticket_id, name:'LEG-…' }.
// LAZY par design : appelé quand l'agent veut RÉPONDRE au client d'un ticket (P2) — PAS à l'ouverture d'une feuille (sinon on créerait
// une conversation vide par ticket consulté). channel = 'email' si courriel connu, sinon 'sms'. initiatedBy='agent' → l'IA n'auto-répond pas.
function getOrCreateConvForLegacyTicket (legacyTicketId, { customer, customerName, email, phone, subject } = {}) {
const lid = String(legacyTicketId || '').replace(/[^0-9]/g, '')
if (!lid) return null
for (const c of conversations.values()) {
if ((c.linkedTickets || []).some(t => t && String(t.legacy_ticket_id) === lid)) return c
}
const em = (email && /@/.test(String(email))) ? String(email).trim() : null
const ph = phone ? String(phone).replace(/[^\d+]/g, '') : null
const conv = createConversation({
phone: ph || null, customer: customer || null,
customerName: customerName || em || ph || ('Ticket ' + lid),
agentEmail: null, subject: subject || ('Ticket ' + lid),
})
if (em) { conv.email = em; conv.channel = 'email' } else if (ph) { conv.channel = 'sms' }
conv.initiatedBy = 'agent'
conv.linkedTickets = conv.linkedTickets || []
conv.linkedTickets.push({ legacy_ticket_id: lid, name: 'LEG-' + lid })
saveToDisk()
log(`Conv ${conv.token} liée au ticket legacy ${lid} (canal ${conv.channel || 'aucun'})`)
return conv
}
// Variante ERPNext Issue : conversation courriel liée à un ticket ERPNext (Issue name = ISS-…).
// Clé = conv.linkedTickets[].name. Idempotent. La réponse du client re-thread ici (subject [Ticket #ISS-…] → TICKET_REF_RX).
function getOrCreateConvForIssue (issueName, { customer, customerName, email, subject } = {}) {
const n = String(issueName || '').trim(); if (!n) return null
for (const c of conversations.values()) {
if ((c.linkedTickets || []).some(t => t && String(t.name) === n)) { if (email && !c.email) c.email = email; if (!c.channel) c.channel = 'email'; return c }
}
const conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || email || ('Ticket ' + n), agentEmail: null, subject: subject || ('Ticket ' + n) })
conv.channel = 'email'
conv.email = email || null
conv.linkedTickets = [{ name: n, subject: subject || '', ts: new Date().toISOString() }]
saveToDisk()
require('./erp').update('Issue', n, { conversation_token: conv.token }).catch(() => {}) // back-link best-effort
log(`Conv ${conv.token} liée au ticket ERPNext ${n}`)
return conv
}
// Variante ERPNext-NATIF (jobs sans ticket osTicket, ex. chaînes d'installation FR-…) : conversation liée à un Dispatch Job.
// Clé = nom du job dans conv.linkedJobs[]. Même logique lazy/idempotente/canal que getOrCreateConvForLegacyTicket.
function findConvByJob (jobName) {
const jn = String(jobName || '').trim(); if (!jn) return null
for (const c of conversations.values()) { if ((c.linkedJobs || []).includes(jn)) return c }
return null
}
function getOrCreateConvForJob (jobName, { customer, customerName, email, phone, subject } = {}) {
const jn = String(jobName || '').trim(); if (!jn) return null
const existing = findConvByJob(jn); if (existing) return existing
const em = (email && /@/.test(String(email))) ? String(email).trim() : null
const ph = phone ? String(phone).replace(/[^\d+]/g, '') : null
const conv = createConversation({ phone: ph || null, customer: customer || null, customerName: customerName || em || ph || jn, agentEmail: null, subject: subject || jn })
if (em) { conv.email = em; conv.channel = 'email' } else if (ph) { conv.channel = 'sms' }
conv.initiatedBy = 'agent'
conv.linkedJobs = conv.linkedJobs || []
conv.linkedJobs.push(jn)
saveToDisk()
log(`Conv ${conv.token} liée au job ${jn} (canal ${conv.channel || 'aucun'})`)
return conv
}
function getConversation (token) {
const conv = conversations.get(token)
if (!conv) return null
if (isExpired(conv)) { conv.status = 'expired'; return null }
return conv
}
function addMessage (conv, { from, text, type = 'text', via = 'web', media = '', html = '', agent = '', agentName = '', fromName = '', fromEmail = '', toEmail = '', toRaw = '', ccRaw = '', emailDate = '', gmail_id = '', sendAs = '' }) {
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 (from === 'agent' && agentName) msg.agentName = agentName // NOM COMPLET de l'agent (sender_full_name + affichage fil), résolu du courriel via Authentik
// fromName = expéditeur RÉEL par message. Priorité : en-tête From (courriel ingéré) ;
// sinon, pour un message d'agent composé chez nous, le NOM COMPLET de l'agent → le fil
// affiche « Louis-Paul Bourdon » (le collègue) et non le login dérivé « Louis ».
if (fromName) msg.fromName = fromName
else if (from === 'agent' && agentName) msg.fromName = agentName
if (fromEmail) msg.fromEmail = fromEmail
if (toEmail) msg.toEmail = toEmail // adresse de DESTINATION principale (mailbox reçue, ex. support@targo.ca) → affichée « à <…> »
if (toRaw) msg.toRaw = toRaw // en-tête To COMPLET (tous les destinataires) → affichage « à moi, Dominique » au dépli (style Gmail)
if (ccRaw) msg.ccRaw = ccRaw // en-tête Cc complet
if (emailDate) msg.emailDate = emailDate // date d'ARRIVÉE du courriel (en-tête Date) → utilisée dans la citation de réponse
if (gmail_id) msg.gmail_id = gmail_id // mapping message↔Gmail (backfill / robustesse)
if (from === 'agent' && sendAs) msg.sendAs = sendAs // identité d'expédition choisie (From) → lue par notifyCustomer
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, channel: conv.channel, 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
}
// Résout des réfs de pièces jointes [{asset, filename, mime}] → [{filename, mime, content(base64)}] depuis le store d'actifs.
// Garde-fous : max 10 pièces, ~20 Mo cumulés.
function resolveAttachments (list) {
if (!Array.isArray(list) || !list.length) return []
const campaigns = require('./campaigns')
const out = []; let total = 0
for (const a of list.slice(0, 10)) {
try {
if (!a || !a.asset) continue
const r = campaigns.readUpload(a.asset)
if (!r || !r.buffer) continue
total += r.buffer.length
if (total > 20 * 1024 * 1024) break
out.push({ filename: String(a.filename || a.asset).slice(0, 200), mime: a.mime || r.mime, content: r.buffer.toString('base64') })
} catch (e) { /* pièce illisible → ignorée */ }
}
return out
}
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 outHtml = await require('./rating').expandRatingMarker(message.html || '', conv)
const r = await gmail.sendMessage({ to: message.replyToOverride || conv.email, cc: message.cc || '', subject: subj, body: message.text || '', html: outHtml, attachments: resolveAttachments(message.attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: await resolveSendFrom(message.sendAs, message.agent) })
if (r && r.id) { conv.lastGmailId = r.id; conv._gmailIds = conv._gmailIds || []; if (!conv._gmailIds.includes(r.id)) conv._gmailIds.push(r.id) } // mémorise l'ID Gmail du sortant → la re-synchro du fil ne le ré-ajoute pas en double
if (r && r.threadId && !conv.threadId) conv.threadId = r.threadId // 1er envoi → mémorise le fil Gmail pour re-threader la réponse (en + du tag [Ticket #…])
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, ISSUE_TYPE_QUEUE } = 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 »).
async function agentSendFrom (email) {
// NOM CANONIQUE d'abord (Authentik « name » : « Louis-Paul Bourdon »), puis
// REPLI sur la dérivation du préfixe courriel (« louis@ » → « Louis »). Corrige
// les courriels courts (louis@) qui n'exposaient que le prénom.
let name = ''
try { name = await require('./auth').getDisplayNameByEmail(email) } catch { /* repli ci-dessous */ }
if (!name) 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}>`
}
// ── Identités d'expédition (anonymisation « groupe » par DÉFAUT) ──────────────
// Avant : le client voyait le nom PERSONNEL de l'agent (« Louis » <support@targo.ca>).
// Maintenant : par défaut on expédie sous le GROUPE (« Support TARGO » <support@targo.ca>).
// L'agent peut choisir une autre identité (alias Gmail VÉRIFIÉ) ou 'personal' (signer de son nom).
// Adresses bornées aux alias vérifiés (sinon Gmail refuse). Configurable :
// GMAIL_SENDER_IDENTITIES (JSON [{name,address}]) + GMAIL_DEFAULT_SENDER.
function parseIdentities (s) { try { const a = JSON.parse(s); return Array.isArray(a) && a.length ? a.filter(x => x && x.address) : null } catch { return null } }
const SENDER_IDENTITIES = parseIdentities(process.env.GMAIL_SENDER_IDENTITIES) || [
{ name: 'Support TARGO', address: 'support@targo.ca' },
{ name: 'Facturation TARGO', address: 'facturation@targo.ca' },
]
function identityToFrom (i) { if (!i || !i.address) return ''; const n = String(i.name || '').replace(/["<>\r\n]/g, '').trim(); return n ? `"${n}" <${i.address}>` : String(i.address) }
const SEND_ALIASES = SENDER_IDENTITIES.map(i => String(i.address).toLowerCase())
const DEFAULT_SENDER = process.env.GMAIL_DEFAULT_SENDER || identityToFrom(SENDER_IDENTITIES[0]) || require('./gmail').sendFrom()
// From sortant : 'personal' = nom de l'agent ; "Nom <alias vérifié>" = tel quel ; sinon défaut groupe.
async function resolveSendFrom (from, agentEmail) {
const v = String(from || '').trim()
if (v === 'personal') return (await agentSendFrom(agentEmail)) || DEFAULT_SENDER
if (v) { const addr = (v.match(/<([^>]+)>/) || [null, v])[1].trim().toLowerCase(); if (SEND_ALIASES.includes(addr)) return v.replace(/[\r\n]/g, '') }
return DEFAULT_SENDER
}
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', {}) }
// Visibilité PAR PERSONNE (réduction du bruit) : { email: { mode:'personnel'|'department'|'tags', tags:[...] } }.
// personnel = seulement assignés/assistant/suivis · department = toute ma file · tags = ma file filtrée à mes sous-tags. Défaut absent = 'department'.
function loadVisibility () { return readJsonFile('/app/data/queue_visibility.json', {}) }
// Suivi de tickets PAR PERSONNE (watch perso) : { email: [ticketName,...] } → toujours visibles dans « Mes tickets ».
function loadTicketFollows () { return readJsonFile('/app/data/ticket_follows.json', {}) }
// Suivi GÉNÉRALISÉ (au-delà des tickets) : { email: { doctype: [name,...] } }. Migre une seule fois l'ancien ticket_follows.json sous « Issue ».
function loadFollows () {
const all = readJsonFile('/app/data/follows.json', null)
if (all) return all
const legacy = loadTicketFollows() // { email: [ticketName] }
const migrated = {}
for (const [email, arr] of Object.entries(legacy || {})) migrated[email] = { Issue: Array.isArray(arr) ? arr.slice() : [] }
writeJsonFile('/app/data/follows.json', migrated)
return migrated
}
function saveFollows (all) {
writeJsonFile('/app/data/follows.json', all)
// Miroir rétro-compatible : garde ticket_follows.json à jour (schéma email → [ticketName]) pour tout lecteur legacy.
const tf = {}; for (const [email, byType] of Object.entries(all || {})) tf[email] = (byType && byType.Issue) ? byType.Issue.slice() : []
writeJsonFile('/app/data/ticket_follows.json', tf)
}
function followsFor (all, email, doctype) { return (email && all[email] && all[email][doctype]) || [] }
// Qui suit ce doc ? Balaie tous les abonnés → liste des courriels qui suivent (doctype,name). Sert aux pastilles « # follower ».
function followersOf (all, doctype, name) {
const out = []
for (const [email, byType] of Object.entries(all || {})) {
if (byType && Array.isArray(byType[doctype]) && byType[doctype].includes(name)) out.push(email)
}
return out
}
// 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, lean = false } = {}) {
const out = []
// Aperçu LÉGER d'un message pour la liste : pas de HTML ni pièces jointes complètes (c'était l'essentiel du payload).
const leanMsg = m => m ? { id: m.id, from: m.from, via: m.via || '', ts: m.ts, text: String(m.text || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 240), hasMedia: !!((m.media && m.media.length) || (m.attachments && m.attachments.length)) } : null
for (const [, conv] of conversations) {
const expired = isExpired(conv)
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, priority: conv.priority || '',
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 ? (lean ? leanMsg(conv.messages[conv.messages.length - 1]) : 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é.
// P2 : courriel = par FIL GMAIL (thread_id) ; SINON fil ISOLÉ (par token). On NE re-fusionne PLUS par adresse
// (`email:adresse` regroupait dans UNE ligne des sujets sans rapport du même expéditeur). SMS = par numéro (continu, voulu).
const baseKey = isEmail
? (c.threadId ? ('thr:' + c.threadId) : ('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)) {
let latest = null; let count = 0; let lastMsg = null; const allMsgs = lean ? null : []
for (const c of d.conversations) {
const conv = conversations.get(c.token)
if (!conv) continue
if (!latest || conv.lastActivity > latest.lastActivity) latest = conv
count += conv.messages.length
if (lean) { const lm = conv.messages.length ? conv.messages[conv.messages.length - 1] : null; if (lm && (!lastMsg || String(lm.ts || '').localeCompare(String(lastMsg.ts || '')) > 0)) lastMsg = lm }
else for (const m of conv.messages) allMsgs.push({ ...m, convToken: c.token })
}
if (!lean) { allMsgs.sort((a, b) => a.ts.localeCompare(b.ts)); lastMsg = allMsgs.length ? allMsgs[allMsgs.length - 1] : null }
// LISTE LÉGÈRE (lean) : on n'envoie PAS tous les messages (payload ~45 Mo → petit) — chargés à l'ouverture de la discussion.
d.messages = lean ? [] : allMsgs
d.messageCount = lean ? count : allMsgs.length
d.subject = (latest && (latest.lastSubject || latest.subject)) || '' // sujet du courriel pour la liste
d.lastMessage = lean ? leanMsg(lastMsg) : lastMsg
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
d.draftAgent = (latest && latest.draft && latest.draft.agent) || null // brouillon en cours → pastille « X rédige » + filtre Brouillons (au chargement)
d.draftTs = (latest && latest.draft && latest.draft.ts) || null
d.priority = (latest && latest.priority) || '' // drapeau de priorité (style ClickUp) pour la liste
}
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)
store.remove(token).catch(() => {}) // miroir Postgres : retire aussi la ligne
saveToDisk()
return true
}
function deleteDiscussionByTokens (tokens) {
let count = 0
for (const t of tokens) if (conversations.delete(t)) { count++; store.remove(t).catch(() => {}) }
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); store.remove(t).catch(() => {}) }
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.queue && QUEUES.includes(conv.queue)) issueData.issue_type = conv.queue // tag DÉPARTEMENT = la file (les 4 files sont des Issue Type valides) → « Mes départements » sur Tickets
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
// Sortant seulement si NOUS écrivons (from=agent) ET pas depuis l'adresse destinataire du ticket
// (sinon une réponse depuis un domaine interne @targo — ex. test/employé destinataire — serait mal classée « Sent »).
const custEmail = String(conv.email || '').toLowerCase()
const fromEmail = String(msg.fromEmail || '').toLowerCase()
const sent = msg.from === 'agent' && (!custEmail || fromEmail !== custEmail)
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 || ''),
sender_full_name: sent ? (msg.agentName || 'Targo Ops') : (conv.customerName || ''), // sortie → prénom+nom de l'agent (jamais le login « louis »)
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) }
}
// ALLER-RETOUR COURRIEL depuis un ticket : lie l'Issue à une conversation email, envoie dans un fil Gmail
// avec sujet [Ticket #ISS-…] + lien direct. La réponse du client re-thread ici (TICKET_REF_RX) → logToLinkedTicket
// → Communication (Received) sur l'Issue → visible dans le panneau ticket. Envoi RÉEL : gaté (feu vert humain).
async function emailTicket ({ issue, to, cc, message, agent, agentName, link } = {}) {
const iss = String(issue || '').trim()
const dest = String(to || '').trim()
if (!iss || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'issue + destinataire courriel valides requis' }
let subject = 'Ticket ' + iss, customer = null
try {
const rows = await require('./erp').list('Issue', { filters: [['name', '=', iss]], fields: ['subject', 'customer'], limit: 1 })
if (rows && rows[0]) { subject = rows[0].subject || subject; customer = rows[0].customer || null }
} catch (e) { log('emailTicket lookup ' + iss + ': ' + e.message) }
const conv = getOrCreateConvForIssue(iss, { customer, email: dest, subject })
if (!conv) return { ok: false, error: 'conversation non créée' }
conv.email = dest; conv.channel = 'email'; if (!conv.lastSubject) conv.lastSubject = subject
const text = String(message || '').trim()
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
let html = esc(text).replace(/\n/g, '<br>')
if (link) html += `<br><br>— <a href="${esc(link)}">Ouvrir le ticket ${esc(iss)} dans OPS</a>`
const msg = addMessage(conv, { from: 'agent', text, via: 'email', html, agent: agent || '', agentName: agentName || '', toEmail: dest, ccRaw: cc || '' })
const chan = await notifyCustomer(conv, { text, html, cc: cc || '', replyToOverride: dest, agent })
await logToLinkedTicket(conv, msg, subject).catch(() => {})
saveToDisk()
return { ok: chan === 'email', channel: chan, conv: conv.token, issue: iss }
}
// P2 (convergence) — MIROIR osTicket : quand un AGENT répond au client sur une conversation liée à un ticket osTicket
// legacy (linkedTickets[].legacy_ticket_id), on consigne le texte dans le fil osTicket (ticket_msg) pour que F reste synchro.
// Note INTERNE (public=0) + préfixe : l'envoi RÉEL au client est DÉJÀ parti par l'Outbox (notifyCustomer) — cette entrée est un
// MIROIR de visibilité, pas un 2e envoi (insert brut via ops_reassign.php → ne déclenche PAS le mailer osTicket). Best-effort.
async function mirrorAgentReplyToLegacyTicket (conv, msg, agentEmail) {
if (!conv || !conv.linkedTickets) return
const lt = conv.linkedTickets.find(t => t && t.legacy_ticket_id)
if (!lt) return
const text = String(msg.text || '').trim() || String(msg.html || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
if (!text) return
const via = conv.channel === 'email' ? 'courriel' : (conv.channel === 'sms' ? 'SMS' : 'Ops')
try { await require('./legacy-dispatch-sync').postTicketLegacy(lt.legacy_ticket_id, `[Réponse envoyée au client par ${via} via Ops]\n${text}`, false, agentEmail) }
catch (e) { log('mirrorAgentReplyToLegacyTicket ' + lt.legacy_ticket_id + ': ' + 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.
// Normalise un sujet pour comparer des fils : minuscules, retire les préfixes Re:/Ré:/Fwd:/Fw:/Tr: répétés, espaces compactés.
function normSubject (s) { return String(s || '').toLowerCase().replace(/^(\s*(re|ré|fwd|fw|tr)\s*:\s*)+/i, '').replace(/\s+/g, ' ').trim() }
// Repli de regroupement PAR COURRIEL (quand le fil Gmail thread_id n'a PAS matché) : on n'unit QUE si le SUJET concorde.
// Sinon deux courriels SANS RAPPORT du même expéditeur (sujets différents) étaient consolidés à tort dans un seul fil.
function findConversationByEmail (email, subject) {
if (!email) return null
const e = String(email).toLowerCase()
const subjN = normSubject(subject)
let best = null
for (const c of conversations.values()) {
if (c.status === 'expired') continue
if (String(c.email || '').toLowerCase() !== e) continue
const cSubjN = normSubject(c.subject || c.lastSubject)
if (!subjN || !cSubjN || subjN !== cSubjN) continue // P2: fusionner SEULEMENT si les DEUX sujets sont présents ET identiques (sujet vide inclus → fils distincts ; on sur-sépare plutôt que sur-fusionner)
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, to, cc, date, subject, body, text, html, message_id, thread_id, gmail_id } = {}) {
const triage = require('./inbox-triage')
const email = triage.extractEmail(from)
const toEmail = triage.extractEmail(to) || '' // adresse de DESTINATION (mailbox reçue, ex. support@targo.ca) → affichée « à <…> » comme Gmail
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, subject) : 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, toEmail, toRaw: to || '', ccRaw: cc || '', emailDate: date || '', 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) }
}
// RE-SYNCHRONISE une conversation EMAIL depuis Gmail : récupère TOUT le fil (getThread = reçus + envoyés) et AJOUTE les messages
// manquants — réponses envoyées DIRECTEMENT dans Gmail (hors hub), courriels arrivés pendant une panne du hub ou sortis de la boîte
// (jamais vus par le poller `in:inbox newer_than:3d`). Dédup STRICTE par ID Gmail + Message-Id (donc nos propres réponses, déjà dans
// `_gmailIds`, ne sont pas dupliquées). Re-trie le fil en ordre chronologique (les messages récupérés peuvent être plus ANCIENS que
// ceux déjà présents) sans bouger `lastActivity` (un backfill n'est pas une « nouvelle activité » qui remonte le fil dans l'Inbox).
async function resyncThread (token) {
const conv = getConversation(token)
if (!conv) return { ok: false, error: 'Conversation introuvable' }
if (conv.channel !== 'email' || !conv.threadId) return { ok: false, error: 'Pas un fil courriel (threadId Gmail manquant)' }
const gmail = require('./gmail')
const triage = require('./inbox-triage')
let msgs
try { msgs = await gmail.getThread(conv.threadId) } catch (e) { return { ok: false, error: 'Gmail: ' + e.message } }
conv._gmailIds = conv._gmailIds || []
conv._emailMsgIds = conv._emailMsgIds || []
const haveGid = new Set(conv._gmailIds.concat(conv.lastGmailId ? [conv.lastGmailId] : []).concat((conv.messages || []).map(x => x.gmail_id).filter(Boolean)))
const haveMid = new Set(conv._emailMsgIds)
const byGid = {}; for (const x of (conv.messages || [])) { if (x.gmail_id) byGid[x.gmail_id] = x } // pour ENRICHIR les messages déjà présents
const origActivity = conv.lastActivity // un backfill ne doit PAS remonter le fil
let added = 0, backfilled = 0
for (const m of msgs) {
const existing = m.id ? byGid[m.id] : null
if (existing) { // message DÉJÀ présent → on enrichit ses destinataires (To/Cc) pour l'affichage « à moi, … », sans le dupliquer
let chg = false
if (!existing.toRaw && m.to) { existing.toRaw = m.to; chg = true }
if (!existing.ccRaw && m.cc) { existing.ccRaw = m.cc; chg = true }
if (!existing.toEmail && m.to) { existing.toEmail = triage.extractEmail(m.to) || ''; chg = true }
if (chg) backfilled++
continue
}
if ((m.id && haveGid.has(m.id)) || (m.messageId && haveMid.has(m.messageId))) continue
const fromEmail = triage.extractEmail(m.from)
const toEmail = triage.extractEmail(m.to) || ''
const senderIsOwn = !!(fromEmail && OWN_DOMAINS.test(fromEmail)) // un de NOS domaines = réponse de l'équipe → message AGENT (pas « customer »)
addMessage(conv, { from: senderIsOwn ? 'agent' : 'customer', text: m.body || m.snippet || '', via: 'email', html: m.html || '', agent: senderIsOwn ? fromEmail : '', fromName: fromDisplayName(m.from), fromEmail, toEmail, toRaw: m.to || '', ccRaw: m.cc || '', emailDate: m.date || '', gmail_id: m.id || '' })
if (m.id) { conv._gmailIds.push(m.id); haveGid.add(m.id) }
if (m.messageId) { conv._emailMsgIds.push(m.messageId); haveMid.add(m.messageId) }
added++
}
if (added || backfilled) {
if (added) conv.messages.sort((a, b) => (Date.parse(a.emailDate || '') || Date.parse(a.ts) || 0) - (Date.parse(b.emailDate || '') || Date.parse(b.ts) || 0))
conv.lastActivity = origActivity || conv.lastActivity
saveToDisk()
sse.broadcast('conv:' + conv.token, 'conv-resync', { token: conv.token, added, backfilled })
sse.broadcast('conversations', 'conv-resync', { token: conv.token, added, backfilled })
}
return { ok: true, added, backfilled, total: conv.messages.length }
}
// 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, channel: conv.channel, 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, cc, bcc, subject, body, html, attachments, customer, customerName, agentEmail, sendAs } = {}) {
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 outHtml = await require('./rating').expandRatingMarker(html || '', conv)
const r = await gmail.sendMessage({ to: dest, cc: cc || '', bcc: bcc || '', subject: withTicketTag(conv, subject || conv.lastSubject || '(sans objet)'), body: body || '', html: outHtml, attachments: resolveAttachments(attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: await resolveSendFrom(sendAs, agentEmail) })
if (r && r.threadId) conv.threadId = r.threadId
if (r && r.id) conv.lastGmailId = r.id
conv.lastSubject = subject || conv.lastSubject
const agentName = agentEmail ? await require('./auth').getDisplayNameByEmail(agentEmail).catch(() => '') : ''
const m = addMessage(conv, { from: 'agent', text: (body || ''), via: 'email', html: html || '', agent: agentEmail || '', agentName, toRaw: dest || '', ccRaw: cc || '', gmail_id: (r && r.id) || '' })
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', '_assign', 'owner'], limit: 200, orderBy: 'modified desc' })
// _assign Frappe = chaîne JSON '["a@b.com",…]' → tableau d'emails (pour le filtre « assignés à moi » côté Ops). Lien conv↔ticket : token du fil qui a créé/rattaché le ticket (clic = ouvrir le fil).
const tok = {}; for (const c of conversations.values()) { for (const t of (c.linkedTickets || [])) { if (t && t.name && !tok[t.name]) tok[t.name] = c.token } }
const tickets = (rows || []).map(r => { let asn = []; try { asn = JSON.parse(r._assign || '[]') } catch (e) { asn = [] } return { ...r, assignees: Array.isArray(asn) ? asn : [], convToken: tok[r.name] || null } })
return json(res, 200, { tickets, recent_days: all ? null : days })
} catch (e) { log('inbox-tickets error:', e.message); return json(res, 200, { tickets: [], error: e.message }) }
}
// Décompte pour l'accueil — chiffres HONNÊTES tirés des VRAIES sources (pas d'_assign ERPNext, quasi vide ici) :
// • inboxActive = conversations ACTIVES (store PG en mémoire) = vrai volume de boîte.
// • mine / mineOverdue = tickets que JE SUIS encore ouverts (le suivi = « Mes tickets » d'OPS), en retard si ouvert > 48 h.
if (p === '/conversations/my-inbox-counts' && method === 'GET') {
try {
const email = String((url && url.searchParams && url.searchParams.get('email')) || req.headers['x-authentik-email'] || '').toLowerCase()
// awaiting = conversations actives dont le DERNIER message vient du client (= en attente de NOTRE réponse) → ACTIONNABLE.
let inboxActive = 0, awaiting = 0
try {
for (const c of conversations.values()) {
if ((c.status || 'active') !== 'active') continue
inboxActive++
const m = c.messages || []; const last = m[m.length - 1]
if (last && (last.from === 'customer' || last.from === 'client' || last.direction === 'inbound')) awaiting++
}
} catch (e) {}
let mine = 0, mineOverdue = 0
if (email) {
const follows = (followsFor(loadFollows(), email, 'Issue') || []).slice(0, 500)
if (follows.length) {
const cntIssue = async (f) => {
try { const r = await erp.raw('/api/method/frappe.client.get_count?doctype=Issue&filters=' + encodeURIComponent(JSON.stringify(f))); return (r && r.data && typeof r.data.message === 'number') ? r.data.message : 0 } catch (e) { return 0 }
}
const base = [['name', 'in', follows], ['status', 'in', ['Open', 'Replied']]]
const cut = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 19).replace('T', ' ') // ouvert > 48 h
const r = await Promise.all([cntIssue(base), cntIssue(base.concat([['creation', '<', cut]]))])
mine = r[0]; mineOverdue = r[1]
}
}
return json(res, 200, { inboxActive, awaiting, mine, mineOverdue })
} catch (e) { return json(res, 200, { inboxActive: 0, awaiting: 0, mine: 0, mineOverdue: 0, 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 (isExpired(conv)) 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(), issueTypeQueue: ISSUE_TYPE_QUEUE, visibility: loadVisibility() })
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)
if (b.visibility) writeJsonFile('/app/data/queue_visibility.json', b.visibility) // mode de visibilité par personne (personnel/department/tags)
return json(res, 200, { ok: true, members: b.members || loadQueueMembers(), labels: b.labels || loadQueueLabels(), visibility: b.visibility || loadVisibility() })
} catch (e) { return json(res, 500, { error: e.message }) }
}
// Suivi PERSO d'un ticket (watch) : GET → mes tickets suivis ; POST {ticket, follow} → (dé)suivre. Toujours visibles dans « Mes tickets ».
// Rétro-compatible : délègue au store généralisé (doctype « Issue »).
if (p === '/conversations/ticket-follow' && (method === 'GET' || method === 'POST')) {
const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
const all = loadFollows()
if (method === 'GET') return json(res, 200, { follows: followsFor(all, email, 'Issue') })
try {
const b = await parseBody(req); const t = String(b.ticket || '').trim()
if (!email || !t) return json(res, 400, { error: 'email + ticket requis' })
const arr = new Set(followsFor(all, email, 'Issue'))
if (b.follow === false) arr.delete(t); else arr.add(t)
;(all[email] || (all[email] = {})).Issue = [...arr]; saveFollows(all)
return json(res, 200, { ok: true, follows: all[email].Issue, following: arr.has(t) })
} catch (e) { return json(res, 500, { error: e.message }) }
}
// Suivi GÉNÉRALISÉ (tout doctype) : GET ?doctype=X → mes noms suivis ; POST {doctype, name, follow} → (dé)suivre.
// Ex. suivre un Dispatch Job, un Customer… au-delà des tickets (Issue). Le suivi « Issue » reste synchronisé avec ticket-follow.
if (p === '/conversations/follow' && (method === 'GET' || method === 'POST')) {
const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
const all = loadFollows()
if (method === 'GET') {
const dt = String(url.searchParams.get('doctype') || '').trim()
if (dt) return json(res, 200, { doctype: dt, follows: followsFor(all, email, dt) })
return json(res, 200, { follows: (email && all[email]) || {} }) // tous doctypes → { doctype: [names] }
}
try {
const b = await parseBody(req); const dt = String(b.doctype || '').trim(); const nm = String(b.name || '').trim()
// Cible = courriel explicite (abonner N'IMPORTE QUEL utilisateur — champ d'assignation « # follower ») OU, à défaut, l'identité de l'appelant.
const target = String(b.email || email || '').toLowerCase()
if (!target || !dt || !nm) return json(res, 400, { error: 'email + doctype + name requis' })
const arr = new Set(followsFor(all, target, dt))
if (b.follow === false) arr.delete(nm); else arr.add(nm)
;(all[target] || (all[target] = {}))[dt] = [...arr]; saveFollows(all)
return json(res, 200, { ok: true, doctype: dt, name: nm, email: target, follows: all[target][dt], following: arr.has(nm) })
} catch (e) { return json(res, 500, { error: e.message }) }
}
// Qui suit ce doc : GET ?doctype=X&name=Y → { followers: [courriels] }. Alimente les pastilles « # » du champ d'assignation.
if (p === '/conversations/followers' && method === 'GET') {
const dt = String(url.searchParams.get('doctype') || '').trim()
const nm = String(url.searchParams.get('name') || '').trim()
if (!dt || !nm) return json(res, 400, { error: 'doctype + name requis' })
return json(res, 200, { doctype: dt, name: nm, followers: followersOf(loadFollows(), dt, nm) })
}
// 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 }) }
}
// ALLER-RETOUR COURRIEL depuis un TICKET (Issue). Gaté (Authentik/proxy). La réponse du client re-thread → Communication sur l'Issue.
if (p === '/conversations/ticket-email' && method === 'POST') {
try { const b = await parseBody(req); const r = await emailTicket({ ...(b || {}), agent: req.headers['x-authentik-email'] || (b && b.agent) || '', agentName: (b && b.agentName) || '' }); 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 tok = req.headers['x-mail-token'] || ''
const want = mailIngestToken()
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 tok = req.headers['x-mail-token'] || ''
const want = mailIngestToken()
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') {
const _t0 = Date.now(); const _all = url.searchParams.get('all') === '1'
const result = listConversations({ includeAll: _all, lean: true }) // liste = résumé léger ; messages chargés à l'ouverture
// Pagination OPTIONNELLE (infinite-scroll Boîte) : ?limit=&offset= sur les DISCUSSIONS (déjà triées lastActivity desc).
// Additif/rétrocompatible : sans `limit`, comportement inchangé (liste complète). Le payload lourd = discussions[].messages → on n'en sert qu'une page.
const limit = parseInt(url.searchParams.get('limit'), 10)
if (Number.isFinite(limit) && limit > 0) {
const offset = Math.max(0, parseInt(url.searchParams.get('offset'), 10) || 0)
const all = result.discussions || []
const page = all.slice(offset, offset + limit)
log(`conversations list: page ${offset}${offset + page.length}/${all.length} fils · ${Date.now() - _t0}ms (all=${_all})`)
// `conversations` (plat, LÉGER, sans corps de messages) renvoyé en entier pour les lookups/compteurs globaux ; seules les discussions sont paginées.
return json(res, 200, { discussions: page, conversations: result.conversations, total: all.length, hasMore: offset + page.length < all.length, offset, limit })
}
const _msgs = (result.discussions || []).reduce((s, d) => s + ((d.messages && d.messages.length) || 0), 0)
log(`conversations list: ${(result.discussions || []).length} fils · ${(result.conversations || []).length} convs · ${_msgs} msgs · ${Date.now() - _t0}ms (all=${_all})`) // mesure C
return json(res, 200, result)
}
// GET /conversations/for-customer?customer=&email=&phone= — fils (email+SMS) d'UN client pour la fiche (timeline customer-360)
if (p === '/conversations/for-customer' && method === 'GET') {
const cust = (url.searchParams.get('customer') || '').trim()
const email = (url.searchParams.get('email') || '').trim().toLowerCase()
const phone = (url.searchParams.get('phone') || '').replace(/\D/g, '').slice(-10)
if (!cust && !email && !phone) return json(res, 200, { discussions: [] })
const { discussions } = listConversations({ includeAll: true })
const mine = discussions.filter(d => {
// Lien EXPLICITE à ce client = toujours inclus (source de vérité).
if (cust && d.customer === cust) return true
// Déjà lié à un AUTRE client = jamais (sinon fuite inter-fiches via email/tél partagé ou interne).
if (cust && d.customer && d.customer !== cust) return false
// Conversation NON liée : repli par email/tél — mais JAMAIS via une de NOS adresses (staff/support @targo.ca…),
// sinon les fils internes/partagés remontent sur toutes les fiches.
const de = String(d.email || '').toLowerCase()
if (email && de === email && !OWN_DOMAINS.test(de)) return true
const dp = String(d.phone || '').replace(/\D/g, '').slice(-10)
if (phone && dp.length === 10 && dp === phone) return true
return false
})
return json(res, 200, { discussions: mine })
}
// 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).
// Identités d'expédition pour le sélecteur « De : » (défaut groupe + alias vérifiés).
if (p === '/conversations/senders' && method === 'GET') {
return json(res, 200, { default: DEFAULT_SENDER, identities: SENDER_IDENTITIES.map(i => ({ name: i.name || '', address: i.address, from: identityToFrom(i) })) })
}
// Rafraîchir À LA DEMANDE : force un pull Gmail immédiat (≠ fetchList qui ne lit que l'état hub déjà ingéré).
if (p === '/conversations/poll-now' && method === 'POST') {
try { const r = await require('./gmail').poll(); return json(res, 200, { ok: true, scanned: (r && r.scanned) || 0, ingested: (r && r.ingested) || 0 }) } catch (e) { return json(res, 200, { ok: false, error: e.message }) }
}
// Préférences de notification PAR UTILISATEUR (clé = courriel agent) : activer/désactiver chaque feed (cloche).
if (p === '/conversations/notif-prefs' && (method === 'GET' || method === 'PUT')) {
const NOTIF_DEFAULTS = { sms: true, webchat: true, '3cx': true, call: true, facebook: true, email: false, ratings: true }
const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
const all = readJsonFile('/app/data/notif_prefs.json', {})
if (method === 'PUT') {
if (!email) return json(res, 400, { error: 'utilisateur inconnu' })
const b = await parseBody(req); const inp = (b && b.prefs) || {}
const clean = {}; for (const k of Object.keys(NOTIF_DEFAULTS)) clean[k] = typeof inp[k] === 'boolean' ? inp[k] : NOTIF_DEFAULTS[k]
all[email] = clean; writeJsonFile('/app/data/notif_prefs.json', all)
return json(res, 200, { ok: true, prefs: clean })
}
return json(res, 200, { prefs: { ...NOTIF_DEFAULTS, ...(all[email] || {}) }, defaults: NOTIF_DEFAULTS })
}
// Abonnement PAR AGENT aux files + notif par file (self-service) : membership = queue_members.json ; notif = queue_notify.json.
// GET → mes files + état ; POST {queue, subscribe?, notify?} → me (dés)abonner / (dé)activer la notif de CETTE file.
if (p === '/conversations/my-queues' && (method === 'GET' || method === 'POST')) {
const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
if (!email) return json(res, 400, { error: 'utilisateur inconnu' })
const members = loadQueueMembers()
const notifyAll = readJsonFile('/app/data/queue_notify.json', {})
if (method === 'POST') {
const b = await parseBody(req); const queue = String(b.queue || '')
if (!QUEUES.includes(queue)) return json(res, 400, { error: 'file inconnue' })
if (typeof b.subscribe === 'boolean') {
const arr = (members[queue] || []).filter(e => String(e).toLowerCase() !== email)
if (b.subscribe) arr.push(email)
members[queue] = arr; writeJsonFile('/app/data/queue_members.json', members)
}
if (typeof b.notify === 'boolean') {
notifyAll[email] = notifyAll[email] || {}; notifyAll[email][queue] = b.notify; writeJsonFile('/app/data/queue_notify.json', notifyAll)
}
}
const mine = QUEUES.filter(q => (members[q] || []).map(e => String(e).toLowerCase()).includes(email))
return json(res, 200, { queues: QUEUES, labels: loadQueueLabels(), mine, notify: notifyAll[email] || {} })
}
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 })
}
// RECHERCHE FLOUE omnichannel (SQL pg_trgm) : nom client / courriel / sujet + corps des messages, typo-tolérante.
// Repli MÉMOIRE (sous-chaîne) si Postgres est indisponible → la recherche fonctionne toujours.
if (p === '/conversations/search' && method === 'GET') {
const q = String(url.searchParams.get('q') || '').trim()
if (!q) return json(res, 200, { ok: true, results: [], source: 'none' })
let results = await store.search(q, 30)
if (results === null) {
const ql = q.toLowerCase()
const mem = []
for (const conv of conversations.values()) {
if (isExpired(conv)) continue
const direct = [conv.customerName, conv.email, conv.lastSubject || conv.subject].filter(Boolean).join(' ').toLowerCase().includes(ql)
let snippet = null
let hit = direct
if (!hit) { const m = (conv.messages || []).find(x => String(x.text || '').toLowerCase().includes(ql)); if (m) { hit = true; snippet = String(m.text || '').replace(/\s+/g, ' ').slice(0, 140) } }
if (hit) mem.push({ token: conv.token, customerName: conv.customerName, email: conv.email, subject: conv.lastSubject || conv.subject, channel: conv.channel, queue: conv.queue, status: conv.status, lastActivity: conv.lastActivity, score: 0, direct, snippet })
}
mem.sort((a, b) => (Number(b.direct) - Number(a.direct)) || String(b.lastActivity || '').localeCompare(String(a.lastActivity || '')))
results = mem.slice(0, 30)
}
return json(res, 200, { ok: true, results, source: store.available() ? 'sql' : 'memory' })
}
// Résout un NUMÉRO DE RÉFÉRENCE de ticket (ex. « 251382 » dans un courriel) → fiche client, via le Dispatch Job lié
// (legacy_ticket_id = n° osTicket ; repli sur le nom du job qui contient « #<ref> »). Pour le sélecteur « Lier une fiche ».
if (p === '/conversations/resolve-ref' && method === 'GET') {
const ref = String(url.searchParams.get('ref') || '').replace(/\D/g, '')
if (ref.length < 3) return json(res, 200, { ok: true, ref, matches: [] })
const out = []; const seen = new Set(); let ticket = null
const push = (m, via, subject) => { if (m && m.name && !seen.has(m.name)) { seen.add(m.name); out.push({ name: m.name, customer_name: m.customer_name || m.name, email: m.email, phone: m.phone, address: m.address, via, subject }) } }
try {
// n° de référence = legacy_ticket_id du Dispatch Job (n° osTicket), ou présent dans le sujet « … · #<ref> »
let jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', ref]], fields: ['name', 'customer', 'service_location', 'subject', 'address', 'legacy_detail', 'status'], limit: 5 })
if (!jobs || !jobs.length) jobs = await erp.list('Dispatch Job', { filters: [['subject', 'like', '%' + ref + '%']], fields: ['name', 'customer', 'service_location', 'subject', 'address', 'legacy_detail', 'status'], limit: 5 })
for (const j of (jobs || [])) {
const tag = 'Ticket #' + ref + (j.status ? ' · ' + j.status : '')
const det = String(j.legacy_detail || '')
const email = (det.match(/Email\s*:\s*([^\s<]+@[^\s<]+)/i) || [])[1]
const phone = (det.match(/(?:T[ée]l[ée]phone|Tel)\s*:\s*([\d().+\-\s]{7,})/i) || [])[1]
const nom = (det.match(/Nom\s*:\s*(.+?)\s+(?:ID|Email|Adresse|T[ée]l)\s*:/i) || [])[1]
const adr = (det.match(/Adresse\s*:\s*(.+?)\s+(?:T[ée]l[ée]phone|Tel|Installa|Service)/i) || [])[1] || j.address
const ids = ((det.match(/\bID\s*:\s*([\d\s]+)/i) || [])[1] || '').trim().split(/\s+/).filter(x => x.length >= 3)
// Identité du ticket (utile même si AUCUNE fiche ERPNext ne correspond — fréquent : client non encore synchronisé)
if (!ticket) ticket = { ref, name: (nom || '').trim(), email: email || '', phone: (phone || '').replace(/\s+/g, ' ').trim(), address: (adr || '').trim(), subject: j.subject || '', status: j.status || '' }
// Résolution de la FICHE : client lié au job → courriel → téléphone → n° de compte → adresse (service location)
if (j.customer) { let cn = j.customer; try { const c = (await erp.list('Customer', { filters: [['name', '=', j.customer]], fields: ['name', 'customer_name'], limit: 1 }))[0]; cn = (c && c.customer_name) || j.customer } catch (e) { /* */ } push({ name: j.customer, customer_name: cn }, tag, j.subject); continue }
let got = 0
if (email) { for (const m of await lookupCustomersByEmail(email, 5)) { push(m, tag + ' · courriel', j.subject); got++ } }
if (!got && phone) { for (const m of await lookupCustomersByPhone(phone, 5)) { push(m, tag + ' · téléphone', j.subject); got++ } }
if (!got && ids.length) { for (const id of ids.slice(0, 4)) { const cs = await erp.list('Customer', { filters: [['legacy_account_id', '=', id]], fields: ['name', 'customer_name'], limit: 2 }); for (const c of (cs || [])) { push({ name: c.name, customer_name: c.customer_name }, tag + ' · n° de compte', j.subject); got++ } } }
if (!got && j.service_location) { try { const sl = (await erp.list('Service Location', { filters: [['name', '=', j.service_location]], fields: ['customer'], limit: 1 }))[0]; if (sl && sl.customer) { const c = (await erp.list('Customer', { filters: [['name', '=', sl.customer]], fields: ['name', 'customer_name'], limit: 1 }))[0]; push({ name: sl.customer, customer_name: (c && c.customer_name) || sl.customer }, tag + ' · adresse', j.subject) } } catch (e) { /* */ } }
}
} catch (e) { log('resolve-ref error: ' + e.message) }
return json(res, 200, { ok: true, ref, matches: out, ticket })
}
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()
// Noms RÉELS (tabUser) → afficher/chercher « Michel Blais » et pas seulement le préfixe courriel « michel » dans l'assignation/@mention. Best-effort.
const names = {}
try {
let pool = null; try { pool = require('./address-db').pool() } catch (e) { /* address-db indispo */ }
if (pool && out.length) { const r = await pool.query('SELECT name, full_name FROM "tabUser" WHERE lower(name) = ANY($1)', [out]); for (const row of r.rows) if (row.full_name) names[String(row.name).toLowerCase()] = row.full_name }
} catch (e) { /* noms best-effort — l'assignation marche sans */ }
return json(res, 200, { agents: out, names })
}
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, priority: conv.priority || '',
assistants: conv.assistants || [], ticketState: conv.ticketState || (conv.status === 'closed' ? 'closed' : 'open'), pendingUntil: conv.pendingUntil || null,
notes: conv.notes || [], linkedTickets: conv.linkedTickets || [], links: conv.links || [], 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)
}
// RE-SYNCHRONISER ce fil depuis Gmail — récupère les réponses envoyées hors hub + les courriels manqués (panne / sortis de la boîte).
if (sub === 'resync' && method === 'POST') {
const r = await resyncThread(token)
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)
}
// Pièces jointes (PDF/JPG) du fil — pour le trombone. Lues à la demande via Gmail puis mises en cache sur le message.
if (sub === 'attachments' && method === 'GET') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const map = {}; let fetched = false
for (const m of (conv.messages || [])) {
if (!m.gmail_id) continue
if (m._att === undefined) {
try { const gm = await gmail.getMessage(m.gmail_id); m._att = (gm.attachments || []).map(a => ({ filename: a.filename, mimeType: a.mimeType, attachmentId: a.attachmentId, size: a.size })); fetched = true } catch (e) { m._att = [] }
}
if (m._att && m._att.length) map[m.id] = m._att.map(a => ({ ...a, gmail_id: m.gmail_id }))
}
if (fetched) saveToDisk()
return json(res, 200, { attachments: map })
}
// Sert une pièce jointe en ligne (clic trombone / source pour la Vision). mime/name fournis par le client (issus de la liste).
if (sub === 'attachment' && method === 'GET') {
const gid = url.searchParams.get('gmail_id') || ''; const att = url.searchParams.get('att') || ''
const mime = url.searchParams.get('mime') || 'application/octet-stream'
const fname = (url.searchParams.get('name') || 'document').replace(/[^\w.\- ]/g, '_')
if (!gid || !att) return json(res, 400, { error: 'gmail_id + att requis' })
try {
const buf = Buffer.from(String(await gmail.getAttachment(gid, att) || '').replace(/-/g, '+').replace(/_/g, '/'), 'base64')
res.writeHead(200, { 'Content-Type': mime, 'Content-Disposition': 'inline; filename="' + fname + '"', 'Cache-Control': 'private, max-age=300' })
return res.end(buf)
} catch (e) { return json(res, 502, { error: 'Gmail: ' + e.message }) }
}
// 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); store.remove(token).catch(() => {}); 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 === 'note' && method === 'PATCH') { // corriger une note (faute de frappe…)
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req); const id = String(b.id || ''); const text = String(b.text || '').trim()
if (!id || !text) return json(res, 400, { error: 'id + text requis' })
const note = (conv.notes || []).find(n => n.id === id); if (!note) return json(res, 404, { error: 'Note introuvable' })
note.text = text; note.edited = new Date().toISOString(); saveToDisk()
sse.broadcast('conversations', 'conv-note-upd', { token, note })
return json(res, 200, { ok: true, note })
}
if (sub === 'note' && method === 'DELETE') { // supprimer une note (erreur)
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req); const id = String(b.id || ''); if (!id) return json(res, 400, { error: 'id requis' })
const before = (conv.notes || []).length
conv.notes = (conv.notes || []).filter(n => n.id !== id)
if (conv.notes.length === before) return json(res, 404, { error: 'Note introuvable' })
saveToDisk()
sse.broadcast('conversations', 'conv-note-del', { token, id })
return json(res, 200, { ok: true, id })
}
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.unlink || b.customer === '') { // délier la fiche → réaffiche « Lier une fiche »
conv.customer = ''; conv.customerName = ''; conv.candidates = null; saveToDisk()
sse.broadcast('conversations', 'conv-update', { token: conv.token, customer: '', customerName: '' })
return json(res, 200, { ok: true, customer: '', customerName: '' })
}
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 })
}
// LIER (interdépendances omnichannel, style ClickUp) : relie CE fil à un autre fil / une fiche client / un ticket.
// Stocké sur `conv.links` (persisté via PG comme le reste). Lien conv↔conv = bidirectionnel. `remove:true` retire.
if (sub === 'relate' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req)
const type = String(b.targetType || '').trim() // 'conversation' | 'customer' | 'ticket'
const id = String(b.targetId || '').trim()
if (!type || !id) return json(res, 400, { error: 'targetType + targetId requis' })
const by = req.headers['x-authentik-email'] || null
const rel = String(b.rel || 'related')
const sameKey = (l) => l.type === type && String(l.id) === id
conv.links = Array.isArray(conv.links) ? conv.links : []
if (b.remove) {
conv.links = conv.links.filter(l => !sameKey(l))
if (type === 'conversation') { const o = getConversation(id); if (o && Array.isArray(o.links)) o.links = o.links.filter(l => !(l.type === 'conversation' && String(l.id) === token)) }
} else {
if (!conv.links.some(sameKey)) conv.links.push({ type, id, label: String(b.targetLabel || '').slice(0, 120), rel, by, at: new Date().toISOString() })
if (type === 'conversation') { // miroir bidirectionnel : l'autre fil pointe aussi vers celui-ci
const o = getConversation(id)
if (o) { o.links = Array.isArray(o.links) ? o.links : []; if (!o.links.some(l => l.type === 'conversation' && String(l.id) === token)) o.links.push({ type: 'conversation', id: token, label: (conv.customerName || conv.email || 'Conversation'), rel, by, at: new Date().toISOString() }) }
}
}
saveToDisk()
sse.broadcast('conv:' + conv.token, 'conv-update', { token: conv.token })
sse.broadcast('conversations', 'conv-update', { token: conv.token })
return json(res, 200, { ok: true, links: conv.links })
}
// PRIORITÉ (style ClickUp) : urgent | high | normal | low | '' (aucune). Sur conv.priority (persiste via PG meta).
if (sub === 'priority' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req)
const p = ['high', 'medium', 'low', 'urgent', 'normal', ''].includes(b.priority) ? b.priority : '' // 3 niveaux (high/medium/low) ; urgent/normal gardés pour rétro-compat
conv.priority = p; saveToDisk()
const payload = { token: conv.token, priority: p }
sse.broadcast('conv:' + conv.token, 'conv-update', payload)
sse.broadcast('conversations', 'conv-update', payload)
return json(res, 200, { ok: true, priority: p })
}
// 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 || '')
const replyTo = body.replyTo ? String(body.replyTo) : null // id du message PRÉCIS auquel le brouillon répond → ré-ancrage dans le fil
conv.draft = (html.trim() || text.trim()) ? { agent: agentEmail, sid, html, text, replyTo, 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)
const attachments = Array.isArray(body.attachments) ? body.attachments : []
if (!text && !media && !html && !attachments.length) return json(res, 400, { error: 'Missing text or media' })
const cc = String(body.cc || '').trim() // Cc éditable sur la réponse (« répondre à tous »)
const toOverride = String(body.to || '').trim() // À éditable (défaut = client si vide)
const agentEmail = req.headers['x-authentik-email']
const from = agentEmail ? 'agent' : 'customer'
const isEmail = conv.channel === 'email'
// Nom complet canonique de l'agent (Authentik) → le fil montre QUEL collègue a répondu/transféré.
const agentName = from === 'agent' ? await require('./auth').getDisplayNameByEmail(agentEmail).catch(() => '') : ''
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 || '', agentName, attachments, sendAs: body.sendAs || '', toRaw: (from === 'agent' && isEmail) ? (toOverride || conv.email || '') : '', ccRaw: cc })
if (from === 'agent') { msg.cc = cc; if (toOverride) msg.replyToOverride = toOverride; msg.notifiedVia = await notifyCustomer(conv, msg) } // email → envoi HTML dans le fil (To/Cc inclus) ; 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é (Issue ERPNext)
if (from === 'agent' && conv.linkedTickets && conv.linkedTickets.some(t => t && t.legacy_ticket_id)) mirrorAgentReplyToLegacyTicket(conv, msg, agentEmail).catch(() => {}) // P2 : miroir réponse → fil osTicket legacy (F sync)
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,
createDraftConversation,
getOrCreateConvForLegacyTicket,
getOrCreateConvForJob,
findConvByJob,
getConversation,
addMessage,
findConversationByPhone,
autoLinkIncomingSms,
triggerAgent,
notifyCustomer,
deleteConversation,
deleteDiscussion,
archiveDiscussion,
}