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