'use strict' const crypto = require('crypto') const fs = require('fs') const path = require('path') const cfg = require('./config') const { log, json, parseBody, lookupCustomerByPhone, lookupCustomersByPhone, lookupCustomersByEmail, createCommunication, readJsonFile, writeJsonFile, stripDataUrl, OWN_DOMAINS } = require('./helpers') const erp = require('./erp') const sse = require('./sse') const store = require('./store-pg') // magasin Postgres DURABLE (miroir best-effort + repli JSON si indispo) const DATA_DIR = path.join(__dirname, '..', 'data') const CONV_FILE = path.join(DATA_DIR, 'conversations.json') const conversations = new Map() const pushSubscriptions = new Map() const agentTimers = new Map() // Jeton d'ingestion webhook (courriel/canal) : env sinon fichier — lu UNE seule fois (mémoïsé) au lieu d'un // fs.readFileSync SYNCHRONE à CHAQUE webhook entrant (bloquait la boucle d'événements). Redémarrage requis pour une rotation. let _mailIngestToken function mailIngestToken () { if (_mailIngestToken === undefined) { _mailIngestToken = (process.env.MAIL_INGEST_TOKEN || '').trim() if (!_mailIngestToken) { try { _mailIngestToken = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) { _mailIngestToken = '' } } } return _mailIngestToken } function loadFromDisk () { try { if (!fs.existsSync(CONV_FILE)) return for (const conv of JSON.parse(fs.readFileSync(CONV_FILE, 'utf8'))) conversations.set(conv.token, conv) log(`Loaded ${conversations.size} conversations from disk`) } catch (e) { log('Failed to load conversations:', e.message) } } // Les conversations COURRIEL sont DURABLES (comme des tickets) → jamais expirées ; SMS/web-chat gardent l'expiration 30 j (lien éphémère). function isExpired (conv) { return !!conv && conv.channel !== 'email' && new Date(conv.expiresAt) < new Date() } // Synchro Postgres DIRTY-ONLY (durable) : au plus toutes les 3 s, n'upsert QUE les conversations dont la // sérialisation a changé depuis la dernière synchro (détection automatique, sans toucher aux ~40 appels saveToDisk). const _syncedJson = new Map() // token → dernière sérialisation persistée en base let _pgTimer = null function syncPg () { if (!store.available() || _pgTimer) return _pgTimer = setTimeout(async () => { _pgTimer = null try { for (const c of conversations.values()) { const j = JSON.stringify(c) if (_syncedJson.get(c.token) === j) continue await store.upsert(c) // entête + messages (normalisé), transaction _syncedJson.set(c.token, j) // marqué synchronisé SEULEMENT après upsert réussi } } catch (e) { /* best-effort ; ré-essai au prochain tick */ } }, 3000) } let saveTimer = null function saveToDisk () { syncPg() // miroir Postgres durable (best-effort, throttlé) — EN PLUS du JSON (repli / rollback instantané) if (saveTimer) return saveTimer = setTimeout(() => { saveTimer = null try { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) fs.writeFileSync(CONV_FILE + '.tmp', JSON.stringify([...conversations.values()], null, 0)) fs.renameSync(CONV_FILE + '.tmp', CONV_FILE) } catch (e) { log('Failed to save conversations:', e.message) } }, 500) } loadFromDisk() // Démarrage Postgres — MAÎTRE (système de record, comme les tickets) : // 1) crée / migre le schéma normalisé `comms` (conversations + messages) ; // 2) Postgres NON VIDE → il fait foi : on (re)charge la Map depuis Postgres ; // 3) Postgres VIDE (1er boot / juste après migration) → on AMORCE depuis le JSON ; // 4) Postgres indisponible → repli total sur le JSON (le hub reste 100 % fonctionnel). // Le JSON reste écrit en double (sauvegarde / rollback). _syncedJson est amorcé pour ne pas tout ré-upserter au 1er changement. ;(async () => { try { if (!(await store.init())) { log('comms-pg: indisponible → lecture sur le JSON'); return } const rows = await store.loadAll() if (rows && rows.length) { conversations.clear() for (const c of rows) if (c && c.token) conversations.set(c.token, c) log(`comms-pg: ${conversations.size} conversations chargées depuis Postgres (MAÎTRE) — ${await store.msgCount()} messages`) } else if (conversations.size) { log(`comms-pg: base vide → amorçage de ${conversations.size} conversations depuis le JSON…`) const done = await store.upsertAll([...conversations.values()]) log(`comms-pg: amorçage terminé — ${done} conversations + ${await store.msgCount()} messages en base`) } for (const c of conversations.values()) _syncedJson.set(c.token, JSON.stringify(c)) // évite de tout ré-upserter au 1er changement } catch (e) { log('comms-pg bootstrap:', e.message) } })() function generateToken () { return crypto.randomBytes(4).toString('base64url') } function createConversation ({ phone, customer, customerName, agentEmail, subject }) { const token = generateToken() const now = new Date().toISOString() const conv = { token, phone, customer: customer || null, customerName: customerName || 'Client', agentEmail: agentEmail || null, subject: subject || 'Conversation', status: 'active', messages: [], createdAt: now, lastActivity: now, expiresAt: new Date(Date.now() + 30 * 24 * 3600000).toISOString(), // 30 j (lien web-chat persistant ; le client peut toujours répondre par SMS) smsCount: 0, pushSub: null, } conversations.set(token, conv) saveToDisk() log(`Conversation created: ${token} for ${phone} (${customerName})`) return conv } // Crée une conversation COURRIEL avec un brouillon pré-rempli (ex. récompense cadeau) à éditer/envoyer // depuis la messagerie centralisée. Le corps contient déjà NOTRE lien /g/ (jamais le lien Giftbit brut). function createDraftConversation ({ customer, customerName, email, subject, html, text, agentEmail }) { const conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || email || 'Client', agentEmail: agentEmail || null, subject: subject || 'Courriel' }) conv.channel = 'email' conv.email = email conv.initiatedBy = 'agent' conv.draft = (html || text) ? { agent: agentEmail || 'system', sid: 'reward', html: html || '', text: text || '', ts: new Date().toISOString() } : null saveToDisk() return conv } // P1 (convergence messages) — lie UN ticket osTicket legacy à UNE conversation comms, find-or-create IDEMPOTENT par legacy_ticket_id. // Réutilise conv.linkedTickets (déjà la clé conv↔ticket pour les Issue ERPNext) avec une entrée { legacy_ticket_id, name:'LEG-…' }. // LAZY par design : appelé quand l'agent veut RÉPONDRE au client d'un ticket (P2) — PAS à l'ouverture d'une feuille (sinon on créerait // une conversation vide par ticket consulté). channel = 'email' si courriel connu, sinon 'sms'. initiatedBy='agent' → l'IA n'auto-répond pas. function getOrCreateConvForLegacyTicket (legacyTicketId, { customer, customerName, email, phone, subject } = {}) { const lid = String(legacyTicketId || '').replace(/[^0-9]/g, '') if (!lid) return null for (const c of conversations.values()) { if ((c.linkedTickets || []).some(t => t && String(t.legacy_ticket_id) === lid)) return c } const em = (email && /@/.test(String(email))) ? String(email).trim() : null const ph = phone ? String(phone).replace(/[^\d+]/g, '') : null const conv = createConversation({ phone: ph || null, customer: customer || null, customerName: customerName || em || ph || ('Ticket ' + lid), agentEmail: null, subject: subject || ('Ticket ' + lid), }) if (em) { conv.email = em; conv.channel = 'email' } else if (ph) { conv.channel = 'sms' } conv.initiatedBy = 'agent' conv.linkedTickets = conv.linkedTickets || [] conv.linkedTickets.push({ legacy_ticket_id: lid, name: 'LEG-' + lid }) saveToDisk() log(`Conv ${conv.token} liée au ticket legacy ${lid} (canal ${conv.channel || 'aucun'})`) return conv } // Variante ERPNext Issue : conversation courriel liée à un ticket ERPNext (Issue name = ISS-…). // Clé = conv.linkedTickets[].name. Idempotent. La réponse du client re-thread ici (subject [Ticket #ISS-…] → TICKET_REF_RX). function getOrCreateConvForIssue (issueName, { customer, customerName, email, subject } = {}) { const n = String(issueName || '').trim(); if (!n) return null for (const c of conversations.values()) { if ((c.linkedTickets || []).some(t => t && String(t.name) === n)) { if (email && !c.email) c.email = email; if (!c.channel) c.channel = 'email'; return c } } const conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || email || ('Ticket ' + n), agentEmail: null, subject: subject || ('Ticket ' + n) }) conv.channel = 'email' conv.email = email || null conv.linkedTickets = [{ name: n, subject: subject || '', ts: new Date().toISOString() }] saveToDisk() require('./erp').update('Issue', n, { conversation_token: conv.token }).catch(() => {}) // back-link best-effort log(`Conv ${conv.token} liée au ticket ERPNext ${n}`) return conv } // Variante ERPNext-NATIF (jobs sans ticket osTicket, ex. chaînes d'installation FR-…) : conversation liée à un Dispatch Job. // Clé = nom du job dans conv.linkedJobs[]. Même logique lazy/idempotente/canal que getOrCreateConvForLegacyTicket. function findConvByJob (jobName) { const jn = String(jobName || '').trim(); if (!jn) return null for (const c of conversations.values()) { if ((c.linkedJobs || []).includes(jn)) return c } return null } function getOrCreateConvForJob (jobName, { customer, customerName, email, phone, subject } = {}) { const jn = String(jobName || '').trim(); if (!jn) return null const existing = findConvByJob(jn); if (existing) return existing const em = (email && /@/.test(String(email))) ? String(email).trim() : null const ph = phone ? String(phone).replace(/[^\d+]/g, '') : null const conv = createConversation({ phone: ph || null, customer: customer || null, customerName: customerName || em || ph || jn, agentEmail: null, subject: subject || jn }) if (em) { conv.email = em; conv.channel = 'email' } else if (ph) { conv.channel = 'sms' } conv.initiatedBy = 'agent' conv.linkedJobs = conv.linkedJobs || [] conv.linkedJobs.push(jn) saveToDisk() log(`Conv ${conv.token} liée au job ${jn} (canal ${conv.channel || 'aucun'})`) return conv } function getConversation (token) { const conv = conversations.get(token) if (!conv) return null if (isExpired(conv)) { conv.status = 'expired'; return null } return conv } function addMessage (conv, { from, text, type = 'text', via = 'web', media = '', html = '', agent = '', fromName = '', fromEmail = '', toEmail = '', toRaw = '', ccRaw = '', emailDate = '', gmail_id = '', sendAs = '' }) { const msg = { id: crypto.randomUUID(), from, text, type, via, media, ts: new Date().toISOString() } if (html) msg.html = html // courriel : HTML complet (rendu WYSIWYG iframe côté Ops) if (from === 'agent' && agent) msg.agent = agent // identité de l'agent qui répond → stats « réponses par employé » (classement Inbox) if (fromName) msg.fromName = fromName // expéditeur RÉEL (en-tête From) PAR message → fil multi-expéditeurs affiche le bon nom if (fromEmail) msg.fromEmail = fromEmail if (toEmail) msg.toEmail = toEmail // adresse de DESTINATION principale (mailbox reçue, ex. support@targo.ca) → affichée « à <…> » if (toRaw) msg.toRaw = toRaw // en-tête To COMPLET (tous les destinataires) → affichage « à moi, Dominique » au dépli (style Gmail) if (ccRaw) msg.ccRaw = ccRaw // en-tête Cc complet if (emailDate) msg.emailDate = emailDate // date d'ARRIVÉE du courriel (en-tête Date) → utilisée dans la citation de réponse if (gmail_id) msg.gmail_id = gmail_id // mapping message↔Gmail (backfill / robustesse) if (from === 'agent' && sendAs) msg.sendAs = sendAs // identité d'expédition choisie (From) → lue par notifyCustomer conv.messages.push(msg) if (!conv.initiatedBy) conv.initiatedBy = from // qui a OUVERT le fil : 'agent' = nous (notif/relance → l'IA n'auto-répond pas) ; 'customer' = demande entrante conv.lastActivity = msg.ts saveToDisk() sse.broadcast('conv:' + conv.token, 'conv-message', { token: conv.token, message: msg }) sse.broadcast('conv-client:' + conv.token, 'conv-message', { message: msg }) sse.broadcast('conversations', 'conv-message', { token: conv.token, channel: conv.channel, customer: conv.customer, customerName: conv.customerName, message: msg }) return msg } function registerPush (token, subscription) { const conv = getConversation(token) if (!conv) return false conv.pushSub = subscription pushSubscriptions.set(token, subscription) saveToDisk() log(`Push subscription registered for conv ${token}`) return true } async function sendPushNotification (conv, message) { if (!conv.pushSub || !cfg.VAPID_PUBLIC_KEY || !cfg.VAPID_PRIVATE_KEY) return false try { const webpush = require('web-push') webpush.setVapidDetails('mailto:support@gigafibre.ca', cfg.VAPID_PUBLIC_KEY, cfg.VAPID_PRIVATE_KEY) await webpush.sendNotification(conv.pushSub, JSON.stringify({ title: 'Gigafibre', body: message.text?.substring(0, 100) || 'Nouveau message', url: `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`, tag: 'conv-' + conv.token, })) log(`Push sent for conv ${conv.token}`) return true } catch (e) { log(`Push failed for conv ${conv.token}:`, e.message) if (e.statusCode === 410 || e.statusCode === 404) { conv.pushSub = null; pushSubscriptions.delete(conv.token); saveToDisk() } return false } } async function sendSmsFallback (conv, text, media) { // Conversation texto : pas de plafond bas sur les réponses MANUELLES de l'agent (chaque envoi = clic humain). // On garde un plafond de sécurité élevé pour éviter tout emballement. if ((conv.smsCount || 0) >= 50) { log(`SMS safety cap reached for conv ${conv.token}, skipping`); return false } const { sendSmsInternal } = require('./twilio') const success = await sendSmsInternal(conv.phone, text, conv.customer, media) if (success) { conv.smsCount = (conv.smsCount || 0) + 1; saveToDisk() } return success } // Résout des réfs de pièces jointes [{asset, filename, mime}] → [{filename, mime, content(base64)}] depuis le store d'actifs. // Garde-fous : max 10 pièces, ~20 Mo cumulés. function resolveAttachments (list) { if (!Array.isArray(list) || !list.length) return [] const campaigns = require('./campaigns') const out = []; let total = 0 for (const a of list.slice(0, 10)) { try { if (!a || !a.asset) continue const r = campaigns.readUpload(a.asset) if (!r || !r.buffer) continue total += r.buffer.length if (total > 20 * 1024 * 1024) break out.push({ filename: String(a.filename || a.asset).slice(0, 200), mime: a.mime || r.mime, content: r.buffer.toString('base64') }) } catch (e) { /* pièce illisible → ignorée */ } } return out } async function notifyCustomer (conv, message) { // Conversation EMAIL → on RÉPOND par courriel dans le MÊME fil Gmail (pas SMS/push). if (conv.channel === 'email' && conv.email) { try { const gmail = require('./gmail') const base = conv.lastSubject || conv.subject || '' const subj = withTicketTag(conv, /^re\s*:/i.test(base) ? base : ('Re: ' + base)) const outHtml = await require('./rating').expandRatingMarker(message.html || '', conv) const r = await gmail.sendMessage({ to: message.replyToOverride || conv.email, cc: message.cc || '', subject: subj, body: message.text || '', html: outHtml, attachments: resolveAttachments(message.attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: resolveSendFrom(message.sendAs, message.agent) }) if (r && r.id) { conv.lastGmailId = r.id; conv._gmailIds = conv._gmailIds || []; if (!conv._gmailIds.includes(r.id)) conv._gmailIds.push(r.id) } // mémorise l'ID Gmail du sortant → la re-synchro du fil ne le ré-ajoute pas en double if (r && r.threadId && !conv.threadId) conv.threadId = r.threadId // 1er envoi → mémorise le fil Gmail pour re-threader la réponse (en + du tag [Ticket #…]) log(`Email reply sent for conv ${conv.token} → ${conv.email} (gmail ${r.id})`) return 'email' } catch (e) { log(`Email reply error for conv ${conv.token}: ${e.message}`); return 'none' } } // Canaux chat externes : la réponse agent est déjà diffusée en direct via SSE (addMessage → conv-client). Pas de SMS/push. if (conv.channel === 'webchat' || conv.channel === '3cx') return 'webchat' if (conv.channel === 'facebook') { log(`FB reply pending (Send API à brancher) conv ${conv.token}`); return 'facebook-pending' } // phase 2 : Messenger Send API // Si le client a ouvert le web-chat et s'est abonné aux push → notification push. const pushed = await sendPushNotification(conv, message) if (pushed) return 'push' // Sinon : SMS/MMS simple — messagerie texte habituelle, PAS de lien web. return (await sendSmsFallback(conv, message.text || '', message.media || '')) ? 'sms' : 'none' } function findConversationByPhone (phone) { const digits = phone.replace(/\D/g, '').slice(-10) for (const [, conv] of conversations) { if (conv.status !== 'active') continue if (conv.phone.replace(/\D/g, '').slice(-10) === digits) return conv } return null } async function autoLinkIncomingSms (phone, text) { const existing = findConversationByPhone(phone) if (existing) return existing const matches = await lookupCustomersByPhone(phone, 6) const customer = matches.length === 1 ? matches[0] : null // 1 fiche = lien auto ; >1 = l'agent choisit (candidates) const { sendSmsInternal } = require('./twilio') if (!customer) { const conv = createConversation({ phone, customer: null, customerName: phone, agentEmail: null, subject: matches.length > 1 ? `SMS entrant (${matches.length} fiches — à rattacher)` : 'SMS entrant (inconnu)' }) conv.smsCount = 0 if (matches.length > 1) conv.candidates = matches // plusieurs fiches partagent ce numéro → choix dans le panneau OPS addMessage(conv, { from: 'customer', text, via: 'sms' }) const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}` const visibleText = 'Bonjour, merci pour votre message! Un agent Gigafibre vous répondra sous peu.' // Always add the greeting to the conversation (even if SMS fails) addMessage(conv, { from: 'agent', text: visibleText, via: 'sms' }) sendSmsInternal(phone, `${visibleText}\n\nPour continuer en ligne:\n${link}`, null).then(sid => { if (sid) conv.smsCount++ }).catch(e => log('Auto-reply SMS error:', e.message)) log(`Auto-created conv ${conv.token} for unknown caller ${phone}`) return conv } const conv = createConversation({ phone, customer: customer.name, customerName: customer.customer_name || customer.name, agentEmail: null, subject: 'SMS entrant', }) conv.smsCount = 0 addMessage(conv, { from: 'customer', text, via: 'sms' }) const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}` const firstName = (customer.customer_name || '').split(' ')[0] || '' const greeting = firstName ? `Bonjour ${firstName}, merci` : 'Bonjour, merci' const visibleText = `${greeting} pour votre message! Un agent vous répondra sous peu.` // Always add the greeting to the conversation (even if SMS fails) addMessage(conv, { from: 'agent', text: visibleText, via: 'sms' }) sendSmsInternal(phone, `${visibleText}\n\nPour continuer la conversation en ligne:\n${link}`, customer.name).then(sid => { if (sid) { conv.smsCount++ log(`Auto-reply sent to ${phone} (${sid})`) } }).catch(e => log('Auto-reply SMS error:', e.message)) log(`Auto-linked SMS from ${phone} → customer ${customer.name}, conv ${conv.token}`) return conv } const todayET = () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) // YYYY-MM-DD (heure de l'Est) // Accusé de réception trivial (« ok », « merci », « c'est bon », un emoji…) → rien à traiter, l'IA ne répond pas. const ACK_WORDS = new Set(['ok', 'okay', 'oki', 'dac', 'daccord', 'accord', 'parfait', 'merci', 'beaucoup', 'nickel', 'super', 'genial', 'bien', 'recu', 'bienrecu', 'compris', 'cest', 'bon', 'cestbon', 'oui', 'ouais', 'yes', 'yep', 'yeah', 'thanks', 'thank', 'you', 'thx', 'ty', 'noted', 'correct', 'exact', 'tres']) function isAck (text) { const t = String(text || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') // minuscules, sans accents if (!t.replace(/[^a-z]/g, '')) return true // emojis/ponctuation seuls (« 👍 », « . », « ✅ ») → accusé const words = t.replace(/['’\-]/g, '').replace(/[^a-z\s]/g, ' ').split(/\s+/).filter(Boolean) // ' et - collés : c'est→cest, d'accord→daccord return words.length > 0 && words.length <= 3 && words.every(w => ACK_WORDS.has(w)) } function triggerAgent (conv) { if (agentTimers.has(conv.token)) clearTimeout(agentTimers.get(conv.token)) agentTimers.set(conv.token, setTimeout(() => { agentTimers.delete(conv.token) runAgentForConv(conv) }, 1500)) } async function runAgentForConv (conv) { if (!cfg.AI_API_KEY || !conv.customer || conv.status !== 'active') return // RÈGLE 1 : l'IA n'auto-répond QUE sur un fil INITIÉ PAR LE CLIENT. Si NOUS l'avons ouvert (notif « maintenance // planifiée », relance, campagne…), un agent gère — l'IA reste muette (un « ok » du client n'appelle aucune réponse auto). const initiator = conv.initiatedBy || (conv.messages && conv.messages[0] && conv.messages[0].from) || 'customer' if (initiator !== 'customer') { log(`AI auto-reply supprimée (fil initié par nous) pour conv ${conv.token}`); return } // RÈGLE 2 : ne pas répondre à un simple accusé de réception (« ok », « merci »…) — rien à traiter, ça crée du bruit. const lastCust = [...(conv.messages || [])].reverse().find(m => m.from === 'customer') if (lastCust && isAck(lastCust.text)) { log(`AI auto-reply supprimée (accusé « ${String(lastCust.text || '').slice(0, 20)} ») pour conv ${conv.token}`); return } // RÈGLE 3 : si un HUMAIN (agent OPS) a écrit dans ce fil AUJOURD'HUI, l'IA NE répond PAS le même jour (un humain gère). if (conv.lastHumanDate && conv.lastHumanDate === todayET()) { log(`AI auto-reply supprimée (fil mené par un humain aujourd'hui) pour conv ${conv.token}`); return } sse.broadcast('conv-client:' + conv.token, 'conv-typing', { typing: true }) try { const { runAgent } = require('./agent') const recentMessages = conv.messages.slice(-20).filter(m => m.from === 'customer' || m.from === 'agent') const reply = await runAgent(conv.customer, conv.customerName, recentMessages, { token: conv.token }) if (reply && conv.status === 'active') { addMessage(conv, { from: 'agent', text: reply, type: 'text', via: 'ai' }) // If the last customer message came via SMS, send AI reply back via SMS + append web chat link const lastCustomerMsg = [...conv.messages].reverse().find(m => m.from === 'customer') if (lastCustomerMsg?.via === 'sms' && conv.phone) { const chatLink = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}` const smsBody = `${reply}\n\nContinuer en ligne: ${chatLink}` const { sendSmsInternal } = require('./twilio') sendSmsInternal(conv.phone, smsBody, conv.customer).then(sid => { if (sid) { conv.smsCount = (conv.smsCount || 0) + 1; log(`AI SMS reply sent to ${conv.phone} (${sid})`) } }).catch(e => log('AI SMS reply error:', e.message)) } createCommunication({ communication_type: 'Communication', communication_medium: 'Chat', sent_or_received: 'Sent', sender: 'ai@gigafibre.ca', sender_full_name: 'Gigafibre Assistant', phone_no: conv.phone, content: reply, subject: `Chat AI: ${conv.subject}`, reference_doctype: 'Customer', reference_name: conv.customer, status: 'Linked', }).catch(() => {}) } } catch (e) { log('Agent error for conv ' + conv.token + ':', e.message) } sse.broadcast('conv-client:' + conv.token, 'conv-typing', { typing: false }) } // ── Coordination (shared inbox) : files = 4 LABELS F (fiable vs 30 départements) + claim ── const { QUEUES, QUEUE_OF, ISSUE_TYPE_QUEUE } = require('./categories') // taxonomie = SOURCE UNIQUE (voir lib/categories.js) const PIPELINE_STAGES = ['Nouveau', 'Qualifié', 'Devis', 'Gagné', 'Perdu'] // pipeline de leads (conversations vente) // ── Garde-fous anti-spam de file (ingest courriel) ──────────────────────────── // Empêche le blast d'une file pour : nos PROPRES notifs réingérées (boucle), les expéditeurs de NOS domaines // (staff/systèmes internes), et les RÉPONSES à un ticket existant (→ rattacher au fil, pas un nouveau routage). // OWN_DOMAINS = source unique dans lib/helpers.js (inclut les SOUS-DOMAINES infra ; jamais un « client »). const OWN_NOTIF_SUBJECT = /^\s*(re\s*:\s*)?\[(inbox|ticket)\s*[·:]/i // nos sujets [Inbox · …] / [Ticket · …] const TICKET_REPLY_SUBJECT = /\[ticket\s*#?\s*[\w-]+\]/i // réponse à un ticket existant : [Ticket #ISS-2026-001] ou [Ticket #250819] // Expéditeur MACHINE (jamais un humain à qui répondre dans l'inbox client) : no-reply, daemon de courriel, panneau d'hébergement… const AUTOMATED_SENDER = /(no-?reply|do-?not-?reply|mailer-daemon)|^(postmaster|bounces?|daemon|cron)@|@(directadmin|bounces?)\./i // « noreply » n'importe où dans le local-part (ex. mail-noreply@google.com) ; daemon/postmaster en début ; sous-domaines infra // Sujet caractéristique d'une notif AUTOMATISÉE (monitoring / panneau d'hébergement) — marqueurs HAUTE PRÉCISION seulement. // NE PAS ajouter de phrases génériques (« service down », « currently down ») : un client peut écrire « mon internet est down » → ne JAMAIS masquer ça. const AUTOMATED_SUBJECT = /automated message generated by|\bdirectadmin\b|cmd_ticket|freshclam|avis de sécurité.*(serveur mandataire|proxy)/i const TICKET_REF_RX = /\[ticket\s*#?\s*([\w-]+)\]/i // extrait le NOM du ticket → route le fil vers la bonne conversation // Préfixe le sujet sortant avec [Ticket #N] quand la conversation est promue en ticket : la réponse du client se rattache au bon fil/ticket. function withTicketTag (conv, subject) { const lt = conv && conv.linkedTickets const tName = (lt && lt.length) ? lt[lt.length - 1].name : null let s = String(subject || conv.lastSubject || '(sans objet)') if (!tName) return s if (new RegExp('\\[ticket\\s*#?\\s*' + tName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\]', 'i').test(s)) return s // déjà étiqueté const m = s.match(/^(re\s*:\s*)/i); const prefix = m ? m[1] : '' const rest = s.slice(prefix.length).replace(/\[ticket\s*#?\s*[\w-]+\]\s*/i, '').trim() return prefix + `[Ticket #${tName}] ` + rest } // Nom d'affichage de l'expéditeur depuis l'en-tête From (« Michaël Brisson » → « 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 » ou « "Nom" » 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 » . 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}>` } // ── Identités d'expédition (anonymisation « groupe » par DÉFAUT) ────────────── // Avant : le client voyait le nom PERSONNEL de l'agent (« Louis » ). // Maintenant : par défaut on expédie sous le GROUPE (« Support TARGO » ). // L'agent peut choisir une autre identité (alias Gmail VÉRIFIÉ) ou 'personal' (signer de son nom). // Adresses bornées aux alias vérifiés (sinon Gmail refuse). Configurable : // GMAIL_SENDER_IDENTITIES (JSON [{name,address}]) + GMAIL_DEFAULT_SENDER. function parseIdentities (s) { try { const a = JSON.parse(s); return Array.isArray(a) && a.length ? a.filter(x => x && x.address) : null } catch { return null } } const SENDER_IDENTITIES = parseIdentities(process.env.GMAIL_SENDER_IDENTITIES) || [ { name: 'Support TARGO', address: 'support@targo.ca' }, { name: 'Facturation TARGO', address: 'facturation@targo.ca' }, ] function identityToFrom (i) { if (!i || !i.address) return ''; const n = String(i.name || '').replace(/["<>\r\n]/g, '').trim(); return n ? `"${n}" <${i.address}>` : String(i.address) } const SEND_ALIASES = SENDER_IDENTITIES.map(i => String(i.address).toLowerCase()) const DEFAULT_SENDER = process.env.GMAIL_DEFAULT_SENDER || identityToFrom(SENDER_IDENTITIES[0]) || require('./gmail').sendFrom() // From sortant : 'personal' = nom de l'agent ; "Nom " = tel quel ; sinon défaut groupe. function resolveSendFrom (from, agentEmail) { const v = String(from || '').trim() if (v === 'personal') return agentSendFrom(agentEmail) || DEFAULT_SENDER if (v) { const addr = (v.match(/<([^>]+)>/) || [null, v])[1].trim().toLowerCase(); if (SEND_ALIASES.includes(addr)) return v.replace(/[\r\n]/g, '') } return DEFAULT_SENDER } function ingestGuard (email, subject) { const s = String(subject || '') const e = String(email || '').trim() if (OWN_NOTIF_SUBJECT.test(s)) return 'own_notif' // notre propre courriel de file → boucle if (e && OWN_DOMAINS.test(e)) return 'internal' // expéditeur interne (incl. sous-domaines infra), pas un client if ((e && AUTOMATED_SENDER.test(e)) || AUTOMATED_SUBJECT.test(s)) return 'automated' // notif machine (no-reply / monitoring / hébergement) → masquée de l'inbox client if (TICKET_REPLY_SUBJECT.test(s)) return 'ticket_reply' // suivi d'un ticket → fil existant, pas de blast return null } function gmailReplyUrl (conv) { return (conv && conv.channel === 'email' && conv.lastMessageId) ? 'https://mail.google.com/mail/u/0/#search/rfc822msgid:' + encodeURIComponent(String(conv.lastMessageId).replace(/^<|>$/g, '')) : null } // Appartenance agent ↔ file : config éditable /app/data/queue_members.json = { "Supports": ["a@targo.ca", …], … }. Vide = aucune notif (sûr). function loadQueueMembers () { return readJsonFile('/app/data/queue_members.json', {}) } // Visibilité PAR PERSONNE (réduction du bruit) : { email: { mode:'personnel'|'department'|'tags', tags:[...] } }. // personnel = seulement assignés/assistant/suivis · department = toute ma file · tags = ma file filtrée à mes sous-tags. Défaut absent = 'department'. function loadVisibility () { return readJsonFile('/app/data/queue_visibility.json', {}) } // Suivi de tickets PAR PERSONNE (watch perso) : { email: [ticketName,...] } → toujours visibles dans « Mes tickets ». function loadTicketFollows () { return readJsonFile('/app/data/ticket_follows.json', {}) } // Suivi GÉNÉRALISÉ (au-delà des tickets) : { email: { doctype: [name,...] } }. Migre une seule fois l'ancien ticket_follows.json sous « Issue ». function loadFollows () { const all = readJsonFile('/app/data/follows.json', null) if (all) return all const legacy = loadTicketFollows() // { email: [ticketName] } const migrated = {} for (const [email, arr] of Object.entries(legacy || {})) migrated[email] = { Issue: Array.isArray(arr) ? arr.slice() : [] } writeJsonFile('/app/data/follows.json', migrated) return migrated } function saveFollows (all) { writeJsonFile('/app/data/follows.json', all) // Miroir rétro-compatible : garde ticket_follows.json à jour (schéma email → [ticketName]) pour tout lecteur legacy. const tf = {}; for (const [email, byType] of Object.entries(all || {})) tf[email] = (byType && byType.Issue) ? byType.Issue.slice() : [] writeJsonFile('/app/data/ticket_follows.json', tf) } function followsFor (all, email, doctype) { return (email && all[email] && all[email][doctype]) || [] } // Qui suit ce doc ? Balaie tous les abonnés → liste des courriels qui suivent (doctype,name). Sert aux pastilles « # follower ». function followersOf (all, doctype, name) { const out = [] for (const [email, byType] of Object.entries(all || {})) { if (byType && Array.isArray(byType[doctype]) && byType[doctype].includes(name)) out.push(email) } return out } // Règles d'inbox définies par l'agent (« Filtrer comme ceci ») : [{ id, field:'from'|'subject', contains, action:'mask'|'allow' }]. « allow » prime sur « mask ». function loadInboxRules () { return readJsonFile('/app/data/inbox_rules.json', []) } function matchInboxRule (email, subject) { const rules = loadInboxRules(); if (!Array.isArray(rules) || !rules.length) return null const e = String(email || '').toLowerCase(); const s = String(subject || '').toLowerCase() let masked = false for (const r of rules) { const needle = String(r.contains || '').toLowerCase().trim(); if (!needle) continue const hay = r.field === 'subject' ? s : e if (hay.includes(needle)) { if (r.action === 'allow') return 'allow'; if (r.action === 'mask') masked = true } } return masked ? 'mask' : null } // Libellés d'AFFICHAGE des files (ex. « Facturation » au singulier) — la CLÉ reste le nom réel (pluriel, = F). Cosmétique uniquement. function loadQueueLabels () { return readJsonFile('/app/data/queue_labels.json', {}) } // Réponses pré-écrites (canned) : /app/data/canned_responses.json = [{id,title,body,category}]. Variables {{client}} {{agent}} {{ticket}} résolues à l'insertion (front). function loadCanned () { const d = readJsonFile('/app/data/canned_responses.json', []); return Array.isArray(d) ? d : (d.responses || []) } function saveCanned (list) { return writeJsonFile('/app/data/canned_responses.json', list || []) } // Pousse un courriel (via Gmail) aux MEMBRES de la file quand une demande y arrive (1 fois). Remplace le fan-out support@ par un ciblage par équipe. async function notifyQueue (conv) { if (!conv || !conv.queue) return conv._notifiedQueues = conv._notifiedQueues || [] if (conv._notifiedQueues.includes(conv.queue)) return const members = (loadQueueMembers()[conv.queue] || []).filter(e => /.+@.+\..+/.test(e)) if (!members.length) return // aucun membre configuré → on ne notifie pas const lastCust = [...(conv.messages || [])].reverse().find(m => m.from === 'customer') const link = gmailReplyUrl(conv) || '' const who = conv.customerName || conv.email || conv.phone || 'Client' const body = `Nouvelle demande dans la file « ${conv.queue} ».\n\nDe : ${who}\nSujet : ${conv.subject || '(courriel)'}\n\n${String((lastCust && lastCust.text) || '').slice(0, 600)}\n\n${link ? 'Ouvrir le fil : ' + link : 'À prendre en charge dans OPS — Inbox.'}\n\n— TARGO Inbox (assignation automatique)` try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Inbox · ${conv.queue}] ${who}: ${String(conv.subject || '').slice(0, 60)}`, body }, { kind: 'queue-notif', label: conv.queue }); conv._notifiedQueues.push(conv.queue); saveToDisk() } catch (e) { log('notifyQueue ' + conv.token + ': ' + e.message) } } // Notifie une file PRÉCISE pour un ticket donné (utilisé par le split : chaque équipe reçoit SON ticket, indépendamment de conv.queue). async function notifyQueueLabel (label, conv, ticketSubject) { const members = (loadQueueMembers()[label] || []).filter(e => /.+@.+\..+/.test(e)) if (!members.length) return const who = (conv && (conv.customerName || conv.email || conv.phone)) || 'Client' const link = gmailReplyUrl(conv) || '' const body = `Nouveau ticket assigné à la file « ${label} » (issu d'une conversation scindée).\n\nDe : ${who}\nTicket : ${ticketSubject}\n\n${link ? 'Fil d\'origine : ' + link : 'À traiter dans OPS — Tickets / Inbox.'}\n\n— TARGO Inbox` try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Inbox · ${label}] ${ticketSubject}`.slice(0, 90), body }, { kind: 'queue-notif', label }) } catch (e) { log('notifyQueueLabel ' + label + ': ' + e.message) } } function listConversations ({ includeAll = false, lean = false } = {}) { const out = [] // Aperçu LÉGER d'un message pour la liste : pas de HTML ni pièces jointes complètes (c'était l'essentiel du payload). const leanMsg = m => m ? { id: m.id, from: m.from, via: m.via || '', ts: m.ts, text: String(m.text || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 240), hasMedia: !!((m.media && m.media.length) || (m.attachments && m.attachments.length)) } : null for (const [, conv] of conversations) { const expired = isExpired(conv) if (!includeAll && (conv.status !== 'active' || expired)) continue out.push({ token: conv.token, phone: conv.phone, customer: conv.customer, customerName: conv.customerName, agentEmail: conv.agentEmail, subject: conv.subject, channel: conv.channel || 'sms', email: conv.email || null, threadId: conv.threadId || null, noise: !!conv.noise, queue: conv.queue || '', assignee: conv.assignee || null, priority: conv.priority || '', suggestedTicket: conv.suggestedTicket || null, linkedTickets: conv.linkedTickets || [], status: expired ? 'expired' : conv.status, messageCount: conv.messages.length, lastActivity: conv.lastActivity, createdAt: conv.createdAt, lastMessage: conv.messages.length ? (lean ? leanMsg(conv.messages[conv.messages.length - 1]) : conv.messages[conv.messages.length - 1]) : null, }) } out.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)) const discussions = {} for (const c of out) { const isEmail = c.channel === 'email' const phoneKey = c.phone ? c.phone.replace(/\D/g, '').slice(-10) : '' // null-safe (les convs EMAIL n'ont pas de téléphone) // Regroupement courriel : par FIL GMAIL d'abord (client + réponses équipe + copies CC = même thread), sinon par courriel CLIENT (jamais une de NOS adresses → support@targointernet.com ne fusionne plus des clients distincts), sinon fil isolé. // P2 : courriel = par FIL GMAIL (thread_id) ; SINON fil ISOLÉ (par token). On NE re-fusionne PLUS par adresse // (`email:adresse` regroupait dans UNE ligne des sujets sans rapport du même expéditeur). SMS = par numéro (continu, voulu). const baseKey = isEmail ? (c.threadId ? ('thr:' + c.threadId) : ('t:' + c.token)) : (phoneKey || ('t:' + c.token)) const key = c.status === 'active' ? `${baseKey}:active` : `${baseKey}:${c.createdAt.slice(0, 10)}` if (!discussions[key]) { const day = c.status === 'active' ? c.lastActivity.slice(0, 10) : c.createdAt.slice(0, 10) discussions[key] = { id: key, phone: c.phone, email: c.email || null, channel: c.channel || 'sms', customer: c.customer, customerName: c.customerName, queue: c.queue || '', assignee: c.assignee || null, date: day, conversations: [], messages: [], lastActivity: c.lastActivity, status: 'closed', } } discussions[key].conversations.push(c) if (c.status === 'active') discussions[key].status = 'active' if (c.queue && !discussions[key].queue) discussions[key].queue = c.queue if (c.assignee) discussions[key].assignee = c.assignee if (c.lastActivity > discussions[key].lastActivity) { discussions[key].lastActivity = c.lastActivity if (discussions[key].status === 'active') discussions[key].date = c.lastActivity.slice(0, 10) } } for (const d of Object.values(discussions)) { let latest = null; let count = 0; let lastMsg = null; const allMsgs = lean ? null : [] for (const c of d.conversations) { const conv = conversations.get(c.token) if (!conv) continue if (!latest || conv.lastActivity > latest.lastActivity) latest = conv count += conv.messages.length if (lean) { const lm = conv.messages.length ? conv.messages[conv.messages.length - 1] : null; if (lm && (!lastMsg || String(lm.ts || '').localeCompare(String(lastMsg.ts || '')) > 0)) lastMsg = lm } else for (const m of conv.messages) allMsgs.push({ ...m, convToken: c.token }) } if (!lean) { allMsgs.sort((a, b) => a.ts.localeCompare(b.ts)); lastMsg = allMsgs.length ? allMsgs[allMsgs.length - 1] : null } // LISTE LÉGÈRE (lean) : on n'envoie PAS tous les messages (payload ~45 Mo → petit) — chargés à l'ouverture de la discussion. d.messages = lean ? [] : allMsgs d.messageCount = lean ? count : allMsgs.length d.subject = (latest && (latest.lastSubject || latest.subject)) || '' // sujet du courriel pour la liste d.lastMessage = lean ? leanMsg(lastMsg) : lastMsg d.token = latest ? latest.token : (d.conversations[0] && d.conversations[0].token) // token actionnable (claim/file/réponse) d.suggestedTicket = (latest && latest.suggestedTicket) || null d.linkedTickets = (latest && latest.linkedTickets) || [] d.gmailReplyUrl = gmailReplyUrl(latest) d.noise = !!(latest && latest.noise) d.readBy = (latest && latest.readBy) || {} // liste : « vu par » + détection non-lu par soi d.draftAgent = (latest && latest.draft && latest.draft.agent) || null // brouillon en cours → pastille « X rédige » + filtre Brouillons (au chargement) d.draftTs = (latest && latest.draft && latest.draft.ts) || null d.priority = (latest && latest.priority) || '' // drapeau de priorité (style ClickUp) pour la liste } return { conversations: out, discussions: Object.values(discussions).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)) } } // Met à la CORBEILLE Gmail les courriels du fil (réversible 30 j) — fil entier si threadId connu, sinon par message. async function trashConvGmail (conv) { if (!conv || conv.channel !== 'email') return const gmail = require('./gmail') if (conv.threadId) { await gmail.trashThread(conv.threadId); return } for (const id of (conv._gmailIds && conv._gmailIds.length ? conv._gmailIds : (conv.lastGmailId ? [conv.lastGmailId] : []))) { try { await gmail.trashMessage(id) } catch (e) { /* */ } } } // Supprime la conversation. opts.trashGmail = sort aussi les courriels liés de la boîte (suppression EXPLICITE par l'agent). function deleteConversation (token, { trashGmail = false } = {}) { const conv = conversations.get(token) if (!conv) return false if (trashGmail) trashConvGmail(conv).catch(e => log('trashConvGmail ' + token + ': ' + e.message)) // best-effort, ne bloque pas la suppression conversations.delete(token) store.remove(token).catch(() => {}) // miroir Postgres : retire aussi la ligne saveToDisk() return true } function deleteDiscussionByTokens (tokens) { let count = 0 for (const t of tokens) if (conversations.delete(t)) { count++; store.remove(t).catch(() => {}) } if (count) saveToDisk() return count } function deleteDiscussion (phone, date) { const phoneDigits = phone.replace(/\D/g, '').slice(-10) const tokensToDelete = [] for (const [token, conv] of conversations) { if (conv.phone.replace(/\D/g, '').slice(-10) !== phoneDigits) continue if (date === 'active') { if (conv.status === 'active') tokensToDelete.push(token) } else if (conv.createdAt.slice(0, 10) === date && conv.status !== 'active') tokensToDelete.push(token) } for (const t of tokensToDelete) { conversations.delete(t); store.remove(t).catch(() => {}) } if (tokensToDelete.length) saveToDisk() return tokensToDelete.length } async function archiveDiscussion (tokens) { const allMsgs = [] let customer = null, customerName = null, phone = null, subject = null for (const token of tokens) { const conv = conversations.get(token) if (!conv) continue if (!customer && conv.customer) customer = conv.customer if (!customerName && conv.customerName) customerName = conv.customerName if (!phone) phone = conv.phone if (!subject && conv.subject) subject = conv.subject for (const m of conv.messages) allMsgs.push(m) } if (!allMsgs.length) return null // If no customer linked, try to lookup by phone number if (!customer && phone) { try { const found = await lookupCustomerByPhone(phone) if (found) { customer = found.name if (!customerName || customerName === phone) customerName = found.customer_name || found.name } } catch {} } allMsgs.sort((a, b) => a.ts.localeCompare(b.ts)) const lines = allMsgs.map(m => { if (m.from === 'system') return `--- ${m.text} ---` const sender = m.from === 'customer' ? (customerName || phone || 'Client') : m.via === 'ai' ? 'AI Assistant' : 'Agent' const time = new Date(m.ts).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) const via = m.via === 'sms' ? ' [SMS]' : m.via === 'ai' ? ' [AI]' : '' return `**${sender}**${via} (${time}):\n${m.text}` }) const issueSubject = `[Archivé] ${subject || 'Discussion'} — ${customerName || phone}` const issueData = { subject: issueSubject, status: 'Closed', priority: 'Low', // issue_type omis (doctype « SMS/Chat » absent → LinkValidationError) description: (phone ? `Téléphone : ${phone}\n\n` : '') + lines.join('\n\n'), } // Only set customer if we have a valid one — avoid sending null/undefined to ERPNext if (customer) issueData.customer = customer if (phone && /.+@.+\..+/.test(phone)) issueData.raised_by = phone // raised_by = EMAIL valide seulement try { const result = await erp.create('Issue', issueData) if (!result.ok) throw new Error(result.error || 'Issue create failed') const issueName = result.name log(`Archived ${tokens.length} conversations → Issue ${issueName} (customer: ${customer || 'none'})`) deleteDiscussionByTokens(tokens) return { issue: issueName, messagesArchived: allMsgs.length, customer: customer || null } } catch (e) { log('Archive error:', e.message); throw e } } // Crée un ERPNext Issue OUVERT depuis une conversation, SANS la supprimer (≠ archiveDiscussion qui ferme+supprime). // La conversation RESTE dans l'historique du client ; lien bidirectionnel (conv.linkedTickets ↔ Issue.conversation_token). async function createTicketFromConversation (token, { title, category, priority, summary } = {}) { const conv = getConversation(token) if (!conv) return { ok: false, error: 'Conversation introuvable' } const who = (m) => m.from === 'customer' ? (conv.customerName || conv.phone || 'Client') : (m.via === 'ai' ? 'AI Assistant' : 'Agent') // Nettoie un corps de message pour le ticket : décode les entités HTML (>…), 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.queue && QUEUES.includes(conv.queue)) issueData.issue_type = conv.queue // tag DÉPARTEMENT = la file (les 4 files sont des Issue Type valides) → « Mes départements » sur Tickets if (conv.email && /.+@.+\..+/.test(conv.email)) issueData.raised_by = conv.email // raised_by = EMAIL valide seulement (sinon InvalidEmailAddressError) const r = await erp.create('Issue', issueData) if (!r.ok) return { ok: false, error: r.error || 'Création Issue échouée' } conv.linkedTickets = conv.linkedTickets || [] conv.linkedTickets.push({ name: r.name, subject, ts: new Date().toISOString() }) saveToDisk() erp.update('Issue', r.name, { conversation_token: conv.token }).catch(() => {}) // lien retour (best-effort si champ custom présent) addMessage(conv, { from: 'system', text: `🎫 Ticket ${r.name} créé depuis cette conversation` }) sse.broadcast('conversations', 'conv-ticket-linked', { token: conv.token, issue: r.name, subject }) log(`Ticket ${r.name} créé depuis conv ${conv.token} (customer: ${conv.customer || 'none'})`) return { ok: true, issue: r.name, subject } } // CHAÎNE DE TICKET : quand une conversation est liée à un Issue, chaque courriel (entrant/sortant) y est journalisé // comme Communication (reference_doctype Issue) → le ticket porte tout le fil, sans doublon de saisie. async function logToLinkedTicket (conv, msg, subject) { if (!conv || !conv.linkedTickets || !conv.linkedTickets.length) return const issue = conv.linkedTickets[conv.linkedTickets.length - 1] // le plus récent if (!issue || !issue.name) return // Sortant seulement si NOUS écrivons (from=agent) ET pas depuis l'adresse destinataire du ticket // (sinon une réponse depuis un domaine interne @targo — ex. test/employé destinataire — serait mal classée « Sent »). const custEmail = String(conv.email || '').toLowerCase() const fromEmail = String(msg.fromEmail || '').toLowerCase() const sent = msg.from === 'agent' && (!custEmail || fromEmail !== custEmail) try { await createCommunication({ communication_type: 'Communication', communication_medium: 'Email', sent_or_received: sent ? 'Sent' : 'Received', sender: sent ? (cfg.MAIL_FROM || 'cc@targointernet.com') : (conv.email || ''), sender_full_name: sent ? (msg.agentName || 'Targo Ops') : (conv.customerName || ''), // sortie → prénom+nom de l'agent (jamais le login « louis ») recipients: sent ? (conv.email || '') : (cfg.MAIL_FROM || 'cc@targointernet.com'), subject: 'Re: ' + (subject || conv.lastSubject || conv.subject || ''), content: msg.html || (msg.text || '').replace(/\n/g, '
'), reference_doctype: 'Issue', reference_name: issue.name, status: 'Linked', }) } catch (e) { log('logToLinkedTicket ' + issue.name + ': ' + e.message) } } // ALLER-RETOUR COURRIEL depuis un ticket : lie l'Issue à une conversation email, envoie dans un fil Gmail // avec sujet [Ticket #ISS-…] + lien direct. La réponse du client re-thread ici (TICKET_REF_RX) → logToLinkedTicket // → Communication (Received) sur l'Issue → visible dans le panneau ticket. Envoi RÉEL : gaté (feu vert humain). async function emailTicket ({ issue, to, cc, message, agent, agentName, link } = {}) { const iss = String(issue || '').trim() const dest = String(to || '').trim() if (!iss || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'issue + destinataire courriel valides requis' } let subject = 'Ticket ' + iss, customer = null try { const rows = await require('./erp').list('Issue', { filters: [['name', '=', iss]], fields: ['subject', 'customer'], limit: 1 }) if (rows && rows[0]) { subject = rows[0].subject || subject; customer = rows[0].customer || null } } catch (e) { log('emailTicket lookup ' + iss + ': ' + e.message) } const conv = getOrCreateConvForIssue(iss, { customer, email: dest, subject }) if (!conv) return { ok: false, error: 'conversation non créée' } conv.email = dest; conv.channel = 'email'; if (!conv.lastSubject) conv.lastSubject = subject const text = String(message || '').trim() const esc = s => String(s).replace(/&/g, '&').replace(//g, '>') let html = esc(text).replace(/\n/g, '
') if (link) html += `

Ouvrir le ticket ${esc(iss)} dans OPS` const msg = addMessage(conv, { from: 'agent', text, via: 'email', html, agent: agent || '', agentName: agentName || '', toEmail: dest, ccRaw: cc || '' }) const chan = await notifyCustomer(conv, { text, html, cc: cc || '', replyToOverride: dest, agent }) await logToLinkedTicket(conv, msg, subject).catch(() => {}) saveToDisk() return { ok: chan === 'email', channel: chan, conv: conv.token, issue: iss } } // P2 (convergence) — MIROIR osTicket : quand un AGENT répond au client sur une conversation liée à un ticket osTicket // legacy (linkedTickets[].legacy_ticket_id), on consigne le texte dans le fil osTicket (ticket_msg) pour que F reste synchro. // Note INTERNE (public=0) + préfixe : l'envoi RÉEL au client est DÉJÀ parti par l'Outbox (notifyCustomer) — cette entrée est un // MIROIR de visibilité, pas un 2e envoi (insert brut via ops_reassign.php → ne déclenche PAS le mailer osTicket). Best-effort. async function mirrorAgentReplyToLegacyTicket (conv, msg, agentEmail) { if (!conv || !conv.linkedTickets) return const lt = conv.linkedTickets.find(t => t && t.legacy_ticket_id) if (!lt) return const text = String(msg.text || '').trim() || String(msg.html || '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim() if (!text) return const via = conv.channel === 'email' ? 'courriel' : (conv.channel === 'sms' ? 'SMS' : 'Ops') try { await require('./legacy-dispatch-sync').postTicketLegacy(lt.legacy_ticket_id, `[Réponse envoyée au client par ${via} via Ops]\n${text}`, false, agentEmail) } catch (e) { log('mirrorAgentReplyToLegacyTicket ' + lt.legacy_ticket_id + ': ' + e.message) } } // PREUVE DE PAIEMENT : OCR (vision Gemini) d'une image envoyée par le client → extrait montant/date/réf → AFFICHE pour décider. // NE écrit RIEN en facturation (F autoritaire) et NE réactive pas (GenieACS pas prêt) : on route vers Facturation, l'humain décide. async function analyzePaymentProof (token, { image, messageId } = {}) { const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' } let src = image if (!src && messageId) { const m = (conv.messages || []).find(x => x.id === messageId); src = m && m.media } if (!src) { const m = [...(conv.messages || [])].reverse().find(x => x.media); src = m && m.media } if (!src) return { ok: false, error: 'Aucune image à analyser' } let base64 try { if (/^https?:\/\//.test(src)) { const r = await fetch(src); const buf = Buffer.from(await r.arrayBuffer()); base64 = buf.toString('base64') } else base64 = stripDataUrl(src) } catch (e) { return { ok: false, error: 'image illisible: ' + e.message } } let result try { result = await require('./vision').extractPayment(base64) } catch (e) { return { ok: false, error: 'OCR: ' + e.message } } conv.paymentProof = { ...result, ts: new Date().toISOString(), messageId: messageId || null } saveToDisk() sse.broadcast('conversations', 'conv-payment', { token: conv.token, payment: conv.paymentProof }) log(`Payment proof conv ${conv.token}: is_payment=${result.is_payment} amount=${result.amount} conf=${result.confidence}`) return { ok: true, payment: conv.paymentProof } } // Trouve une conversation EMAIL existante par adresse (canal email) — pour regrouper le fil d'un même expéditeur. // Normalise un sujet pour comparer des fils : minuscules, retire les préfixes Re:/Ré:/Fwd:/Fw:/Tr: répétés, espaces compactés. function normSubject (s) { return String(s || '').toLowerCase().replace(/^(\s*(re|ré|fwd|fw|tr)\s*:\s*)+/i, '').replace(/\s+/g, ' ').trim() } // Repli de regroupement PAR COURRIEL (quand le fil Gmail thread_id n'a PAS matché) : on n'unit QUE si le SUJET concorde. // Sinon deux courriels SANS RAPPORT du même expéditeur (sujets différents) étaient consolidés à tort dans un seul fil. function findConversationByEmail (email, subject) { if (!email) return null const e = String(email).toLowerCase() const subjN = normSubject(subject) let best = null for (const c of conversations.values()) { if (c.status === 'expired') continue if (String(c.email || '').toLowerCase() !== e) continue const cSubjN = normSubject(c.subject || c.lastSubject) if (!subjN || !cSubjN || subjN !== cSubjN) continue // P2: fusionner SEULEMENT si les DEUX sujets sont présents ET identiques (sujet vide inclus → fils distincts ; on sur-sépare plutôt que sur-fusionner) if (!best || String(c.lastActivity) > String(best.lastActivity)) best = c } return best } // Regroupement PAR FIL GMAIL (thread_id) — la VRAIE clé d'une discussion : le client, nos réponses et les copies CC à support@ partagent le même thread_id Gmail, peu importe l'expéditeur de chaque message. Évite aussi de fusionner des fils distincts qui partagent une adresse interne (support@targointernet.com). function findConversationByThreadId (threadId) { if (!threadId) return null let best = null for (const c of conversations.values()) { if (c.status === 'expired') continue if (c.threadId && String(c.threadId) === String(threadId)) { if (!best || String(c.lastActivity) > String(best.lastActivity)) best = c } } return best } // Trouve la conversation liée à un ticket donné — pour rattacher une réponse « [Ticket #N] » au bon fil, même hors fil Gmail. function findConversationByTicketName (name) { if (!name) return null const n = String(name).toLowerCase() let best = null for (const c of conversations.values()) { if (c.status === 'expired' || c.status === 'merged') continue if ((c.linkedTickets || []).some(t => String(t.name || '').toLowerCase() === n)) { if (!best || String(c.lastActivity) > String(best.lastActivity)) best = c } } return best } // INGESTION d'un courriel entrant (poussé par n8n ou un poller IMAP). Crée/append une conversation canal EMAIL, // lance le triage IA, et si pertinent attache une SUGGESTION de ticket (affichée dans l'Inbox : Créer / Ignorer). async function ingestEmail ({ from, to, cc, date, subject, body, text, html, message_id, thread_id, gmail_id } = {}) { const triage = require('./inbox-triage') const email = triage.extractEmail(from) const toEmail = triage.extractEmail(to) || '' // adresse de DESTINATION (mailbox reçue, ex. support@targo.ca) → affichée « à <…> » comme Gmail const content = String(body || text || '').slice(0, 8000) if (message_id) { for (const c of conversations.values()) { if (Array.isArray(c._emailMsgIds) && c._emailMsgIds.includes(message_id)) return { ok: true, dedup: true, token: c.token } } } // idempotent const guard = ingestGuard(email, subject) // garde-fous anti-spam de file (boucle / interne / réponse-ticket) if (guard === 'own_notif') { log('ingestEmail: skip own notif loop — ' + String(subject || '').slice(0, 60)); return { ok: true, skipped: 'own_notif' } } // nos propres courriels de file → ne pas réingérer const t = await triage.classifyEmail({ from, subject, body: content }).catch(e => { log('ingestEmail triage error:', e.message); return null }) // Réponse à un ticket promu : « [Ticket #N] » dans le sujet → rattache au fil exact (robuste même hors fil Gmail). const tref = (String(subject || '').match(TICKET_REF_RX) || [])[1] // On ne REGROUPE jamais par une de NOS adresses (support@targo.ca, *@targointernet.com…) : sinon tout le sortant ré-ingéré (From: support@targo.ca) se collapse dans un seul fil → des conversations sans lien fusionnent + héritent d'un mauvais nom. Voir OWN_DOMAINS. let conv = (tref && findConversationByTicketName(tref)) || (thread_id && findConversationByThreadId(thread_id)) // MÊME fil Gmail = MÊME discussion (client + nos réponses + copies CC à support@), peu importe l'expéditeur de chaque message || (email && !OWN_DOMAINS.test(email) ? findConversationByEmail(email, subject) : null) const isNewConv = !conv if (!conv) { // Identité d'affichage = qui a VRAIMENT envoyé (en-tête From) en priorité, puis une fiche client liée PAR COURRIEL (fiable), puis le courriel. // On n'utilise PAS le nom DEVINÉ par l'IA depuis le CONTENU (t.customer_name sans fiche liée) comme identité → sinon un courriel « gilles@ » s'affiche « Sylvie Juteau ». const linkedName = (t && t.customer) ? t.customer_name : '' conv = createConversation({ phone: null, customer: (t && t.customer) || null, customerName: fromDisplayName(from) || linkedName || email || 'Courriel', agentEmail: null, subject: subject || '(courriel)' }) conv.channel = 'email'; conv.email = email } conv._emailMsgIds = conv._emailMsgIds || []; if (message_id) conv._emailMsgIds.push(message_id) if (thread_id) conv.threadId = thread_id // pour répondre DANS le même fil Gmail if (message_id) conv.lastMessageId = message_id // In-Reply-To/References if (gmail_id) { conv.lastGmailId = gmail_id; conv._gmailIds = conv._gmailIds || []; if (!conv._gmailIds.includes(gmail_id)) conv._gmailIds.push(gmail_id) } // pour spam/corbeille Gmail ciblés conv.lastSubject = subject || conv.lastSubject || conv.subject // Dédup DOUBLE-ENVOI : même expéditeur, corps QUASI identique, < 3 min (Message-ID différent → la dédup par ID ne l'attrape pas). // FLOU : certains clients renvoient le même courriel à ~2 s d'intervalle avec 1 caractère de différence (encodage) → comparer les 140 premiers car. + écart de longueur < 12 %, pas un === strict. const _norm = s => String(s || '').replace(/\s+/g, ' ').trim() const _last = conv.messages.length ? conv.messages[conv.messages.length - 1] : null if (_last && _last.from === 'customer') { const a = _norm(_last.text), b = _norm(content) const near = !!a && !!b && (a === b || (a.length > 80 && b.length > 80 && a.slice(0, 140) === b.slice(0, 140) && Math.abs(a.length - b.length) <= Math.max(a.length, b.length) * 0.12)) if (near && (Date.now() - new Date(_last.ts).getTime()) < 180000) { log('ingestEmail: skip double-envoi (quasi-identique < 3 min) — ' + email); saveToDisk() return { ok: true, skipped: 'double_send', token: conv.token } } } // Corps = message du fil (le sujet est affiché comme EN-TÊTE → on ne le recolle PAS dans le corps : évite la double en-tête). // Expéditeur d'un de NOS domaines (membre de l'équipe qui répond via Gmail, en copie CC à support@) → message AGENT, pas « customer » (sinon il s'affiche comme un message du client dans le fil). const senderIsOwn = !!(email && OWN_DOMAINS.test(email)) const emailMsg = addMessage(conv, { from: senderIsOwn ? 'agent' : 'customer', text: content, via: 'email', html: html || '', agent: senderIsOwn ? email : '', fromName: fromDisplayName(from), fromEmail: email, toEmail, toRaw: to || '', ccRaw: cc || '', emailDate: date || '', gmail_id: gmail_id || '' }) if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, emailMsg, subject).catch(() => {}) // chaîne : alimente le ticket lié // ── MASQUAGE + ROUTAGE : décidés UNE SEULE FOIS, à la CRÉATION du fil ── // Sur une RÉPONSE à un fil existant (même thread Gmail), on NE ré-évalue PAS : sinon une réponse interne de l'équipe (guard 'internal') pourrait MASQUER un fil client visible, ou re-router / écraser le triage du client. if (isNewConv) { // Auto-masque le bruit machine (no-reply/infra) et l'interne, MAIS JAMAIS un vrai lead détecté par l'IA (vente/support/facturation/installation…). Les RÈGLES utilisateur priment. const LEAD_TYPES = ['vente', 'support', 'facturation', 'installation', 'telephonie', 'television'] const isLead = !!(t && !t.noise && LEAD_TYPES.includes(t.type)) let masked = !!(t && t.noise) || guard === 'automated' || (guard === 'internal' && !isLead) const rule = matchInboxRule(email, subject) // 'allow' | 'mask' | null if (rule === 'allow') masked = false else if (rule === 'mask') masked = true conv.noise = masked if (t) { conv.triage = t if (!conv.noise && guard !== 'ticket_reply') { // visible et pas une réponse à un ticket existant → routage/suggestion auto OK if (!conv.queue) conv.queue = QUEUE_OF[t.type] || '' // route vers le LABEL (file) ; réorientable d'un clic if (t.suggest_ticket && !(conv.linkedTickets && conv.linkedTickets.length)) { conv.suggestedTicket = { title: t.suggested_title, category: t.category, priority: 'Medium', reason: t.reason, is_customer: t.is_customer, ts: new Date().toISOString() } } if (t.type === 'vente' && !conv.pipeline) conv.pipeline = { stage: 'Nouveau', value: 0, updatedAt: new Date().toISOString() } // lead → entre dans le pipeline } } } else { // Fil existant : une règle utilisateur EXPLICITE peut tout de même ré-afficher/masquer ; sinon on ne touche pas à la visibilité ni au routage. const rule = matchInboxRule(email, subject) if (rule === 'allow') conv.noise = false else if (rule === 'mask') conv.noise = true } try { require('./coupon-triage').onEmail({ from, subject, body: content, conv }) } catch (e) { log('coupon onEmail: ' + e.message) } // Levier 1 : alerte coupons (ne bloque pas l'ingest) saveToDisk() sse.broadcast('conversations', 'conv-email', { token: conv.token, email, triage: t }) if (conv.queue && !conv.noise && !conv.assignee && !guard) notifyQueue(conv).catch(() => {}) // pousse aux membres de la file ; JAMAIS sur interne/réponse-ticket/notif (anti-spam) return { ok: true, token: conv.token, triage: t, suggested_ticket: !!(conv.suggestedTicket) } } // RE-SYNCHRONISE une conversation EMAIL depuis Gmail : récupère TOUT le fil (getThread = reçus + envoyés) et AJOUTE les messages // manquants — réponses envoyées DIRECTEMENT dans Gmail (hors hub), courriels arrivés pendant une panne du hub ou sortis de la boîte // (jamais vus par le poller `in:inbox newer_than:3d`). Dédup STRICTE par ID Gmail + Message-Id (donc nos propres réponses, déjà dans // `_gmailIds`, ne sont pas dupliquées). Re-trie le fil en ordre chronologique (les messages récupérés peuvent être plus ANCIENS que // ceux déjà présents) sans bouger `lastActivity` (un backfill n'est pas une « nouvelle activité » qui remonte le fil dans l'Inbox). async function resyncThread (token) { const conv = getConversation(token) if (!conv) return { ok: false, error: 'Conversation introuvable' } if (conv.channel !== 'email' || !conv.threadId) return { ok: false, error: 'Pas un fil courriel (threadId Gmail manquant)' } const gmail = require('./gmail') const triage = require('./inbox-triage') let msgs try { msgs = await gmail.getThread(conv.threadId) } catch (e) { return { ok: false, error: 'Gmail: ' + e.message } } conv._gmailIds = conv._gmailIds || [] conv._emailMsgIds = conv._emailMsgIds || [] const haveGid = new Set(conv._gmailIds.concat(conv.lastGmailId ? [conv.lastGmailId] : []).concat((conv.messages || []).map(x => x.gmail_id).filter(Boolean))) const haveMid = new Set(conv._emailMsgIds) const byGid = {}; for (const x of (conv.messages || [])) { if (x.gmail_id) byGid[x.gmail_id] = x } // pour ENRICHIR les messages déjà présents const origActivity = conv.lastActivity // un backfill ne doit PAS remonter le fil let added = 0, backfilled = 0 for (const m of msgs) { const existing = m.id ? byGid[m.id] : null if (existing) { // message DÉJÀ présent → on enrichit ses destinataires (To/Cc) pour l'affichage « à moi, … », sans le dupliquer let chg = false if (!existing.toRaw && m.to) { existing.toRaw = m.to; chg = true } if (!existing.ccRaw && m.cc) { existing.ccRaw = m.cc; chg = true } if (!existing.toEmail && m.to) { existing.toEmail = triage.extractEmail(m.to) || ''; chg = true } if (chg) backfilled++ continue } if ((m.id && haveGid.has(m.id)) || (m.messageId && haveMid.has(m.messageId))) continue const fromEmail = triage.extractEmail(m.from) const toEmail = triage.extractEmail(m.to) || '' const senderIsOwn = !!(fromEmail && OWN_DOMAINS.test(fromEmail)) // un de NOS domaines = réponse de l'équipe → message AGENT (pas « customer ») addMessage(conv, { from: senderIsOwn ? 'agent' : 'customer', text: m.body || m.snippet || '', via: 'email', html: m.html || '', agent: senderIsOwn ? fromEmail : '', fromName: fromDisplayName(m.from), fromEmail, toEmail, toRaw: m.to || '', ccRaw: m.cc || '', emailDate: m.date || '', gmail_id: m.id || '' }) if (m.id) { conv._gmailIds.push(m.id); haveGid.add(m.id) } if (m.messageId) { conv._emailMsgIds.push(m.messageId); haveMid.add(m.messageId) } added++ } if (added || backfilled) { if (added) conv.messages.sort((a, b) => (Date.parse(a.emailDate || '') || Date.parse(a.ts) || 0) - (Date.parse(b.emailDate || '') || Date.parse(b.ts) || 0)) conv.lastActivity = origActivity || conv.lastActivity saveToDisk() sse.broadcast('conv:' + conv.token, 'conv-resync', { token: conv.token, added, backfilled }) sse.broadcast('conversations', 'conv-resync', { token: conv.token, added, backfilled }) } return { ok: true, added, backfilled, total: conv.messages.length } } // INGESTION CANAL GÉNÉRIQUE — web chat (nouveau site), 3CX, Facebook Messenger… Tout tombe dans l'INBOX PARTAGÉ // (file + claim), donc AUCUNE capture mono-agent : si l'agent est absent et que le client réécrit, le fil reste visible par tous. // Clé de regroupement = channel + external_id (session web / PSID Facebook) → un seul fil par interlocuteur, réponses routées. function findConversationByChannelKey (key) { if (!key) return null let best = null for (const c of conversations.values()) { if (c.channelKey === key && (!best || String(c.lastActivity) > String(best.lastActivity))) best = c } return best } async function channelIngest ({ channel, external_id, from, name, text, html, customer, message_id } = {}) { const ch = String(channel || 'webchat').toLowerCase() const key = ch + ':' + String(external_id || from || '').trim() const content = String(text || '').slice(0, 8000) if (message_id) { for (const c of conversations.values()) { if (Array.isArray(c._chanMsgIds) && c._chanMsgIds.includes(message_id)) return { ok: true, dedup: true, token: c.token } } } let conv = findConversationByChannelKey(key) if (!conv) { conv = createConversation({ phone: null, customer: customer || null, customerName: name || from || ({ webchat: 'Visiteur web', facebook: 'Messenger', '3cx': 'Chat 3CX' }[ch] || 'Chat'), subject: ({ webchat: 'Chat site', facebook: 'Facebook Messenger', '3cx': 'Chat 3CX' }[ch] || 'Chat') }) conv.channel = ch; conv.channelKey = key; conv.channelExternalId = String(external_id || from || '') } conv._chanMsgIds = conv._chanMsgIds || []; if (message_id) conv._chanMsgIds.push(message_id) const msg = addMessage(conv, { from: 'customer', text: content, via: ch, html: html || '' }) // Triage IA léger (réutilise le classifieur courriel) → file + suggestion de ticket, comme un courriel. try { const t = await require('./inbox-triage').classifyEmail({ from: from || name || ch, subject: conv.subject, body: content }) if (t) { conv.triage = t; conv.noise = !!t.noise; if (!t.noise && !conv.queue) conv.queue = QUEUE_OF[t.type] || '' ; if (!t.noise && t.suggest_ticket && !(conv.linkedTickets && conv.linkedTickets.length)) conv.suggestedTicket = { title: t.suggested_title, category: t.category, priority: 'Medium', reason: t.reason, ts: new Date().toISOString() } } } catch (e) { /* triage best-effort */ } if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, msg, conv.subject).catch(() => {}) saveToDisk() sse.broadcast('conversations', 'conv-message', { token: conv.token, channel: conv.channel, customer: conv.customer, customerName: conv.customerName, message: msg }) if (conv.queue && !conv.noise && !conv.assignee) notifyQueue(conv).catch(() => {}) // file notifiée → personne n'est « propriétaire » unique return { ok: true, token: conv.token, channel: ch } } // Envoi SMS sortant réutilisable (orchestrateur NL, FAB…) : crée/append une conversation par téléphone + envoie (notifyCustomer). async function sendSms ({ phone, customer, customerName, message, media } = {}) { const digits = String(phone || '').replace(/\D/g, '') if (digits.length < 10) return { ok: false, error: 'numéro invalide' } if (!message && !media) return { ok: false, error: 'message requis' } let conv = findConversationByPhone(digits) if (!conv) conv = createConversation({ phone: digits, customer: customer || null, customerName: customerName || digits, subject: 'SMS' }) else if (customer && !conv.customer) { conv.customer = customer; conv.customerName = customerName || conv.customerName } const msg = addMessage(conv, { from: 'agent', text: message || '', via: 'web', type: media ? 'image' : 'text', media: media || '' }) msg.notifiedVia = await notifyCustomer(conv, msg) conv.lastHumanDate = todayET(); saveToDisk() return { ok: true, token: conv.token, via: msg.notifiedVia } } // Compose un NOUVEAU courriel SORTANT (≠ réponse) : envoie via Gmail + crée/append une conversation EMAIL suivie. async function sendNewEmail ({ to, cc, bcc, subject, body, html, attachments, customer, customerName, agentEmail, sendAs } = {}) { const triage = require('./inbox-triage') const dest = triage.extractEmail(to) || String(to || '').trim() if (!dest || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'destinataire courriel invalide' } const gmail = require('./gmail') let conv = findConversationByEmail(dest) if (!conv) { conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || dest, subject: subject || 'Courriel' }); conv.channel = 'email'; conv.email = dest } // réponse dans le fil si la conversation existe déjà (threadId connu) const outHtml = await require('./rating').expandRatingMarker(html || '', conv) const r = await gmail.sendMessage({ to: dest, cc: cc || '', bcc: bcc || '', subject: withTicketTag(conv, subject || conv.lastSubject || '(sans objet)'), body: body || '', html: outHtml, attachments: resolveAttachments(attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: resolveSendFrom(sendAs, agentEmail) }) if (r && r.threadId) conv.threadId = r.threadId if (r && r.id) conv.lastGmailId = r.id conv.lastSubject = subject || conv.lastSubject const m = addMessage(conv, { from: 'agent', text: (body || ''), via: 'email', html: html || '', agent: agentEmail || '', toRaw: dest || '', ccRaw: cc || '', gmail_id: (r && r.id) || '' }) if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, m, subject).catch(() => {}) conv.lastHumanDate = todayET(); saveToDisk() log(`Nouveau courriel sortant → ${dest} (gmail ${r && r.id})`) return { ok: true, token: conv.token, gmail: r } } // ── SPLIT : détecte les sujets distincts d'une conversation (ex. « facturation » + « télévision ») ── // detectTopics ne crée RIEN (aperçu) ; splitConversation crée 1 ticket par sujet (réutilise createTicketFromConversation). async function detectSplitTopics (token) { const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' } if (!cfg.AI_API_KEY) return { ok: false, error: 'IA indisponible' } const msgs = (conv.messages || []).filter(m => m.from !== 'system').slice(-12).map(m => (m.from === 'customer' ? 'CLIENT' : 'AGENT') + ': ' + String(m.text || '').slice(0, 400)).join('\n') const sys = 'Tu analyses une conversation de support TARGO/Gigafibre (FAI). Détecte les PROBLÈMES/DEMANDES DISTINCTS qu\'elle contient. Renvoie UNIQUEMENT du JSON : {"topics":[{"title":"titre court du problème","category":"Facturation|Support|Vente|Installation|Television|Telephonie|Autre","queue":"Facturations|Service à la clientèle|Supports|Technicien"}]}. UN SEUL sujet ⇒ un seul élément. Ne scinde QUE si les sujets sont réellement indépendants (ex. une question de facture ET un problème de télé). N\'invente rien.' let out try { out = await require('./ai').chat({ task: 'split', maxTokens: 500, temperature: 0.1, messages: [{ role: 'system', content: sys }, { role: 'user', content: 'CONVERSATION :\n' + msgs }] }) } catch (e) { return { ok: false, error: 'IA : ' + e.message } } let parsed; try { parsed = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0]) } catch (e) { return { ok: false, error: 'réponse IA illisible' } } const topics = Array.isArray(parsed.topics) ? parsed.topics.filter(t => t && t.title).slice(0, 6) : [] return { ok: true, topics, multi: topics.length > 1 } } // Crée 1 ticket par sujet. La conversation RESTE (lien vers tous les tickets via linkedTickets). Notifie la file de chaque sujet. async function splitConversation (token, { topics } = {}) { const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' } let list = Array.isArray(topics) && topics.length ? topics : null if (!list) { const d = await detectSplitTopics(token); if (!d.ok) return d; list = d.topics } if (!list.length) return { ok: false, error: 'Aucun sujet détecté' } const tickets = [] for (const t of list) { const r = await createTicketFromConversation(token, { title: t.title, category: t.category }).catch(e => ({ ok: false, error: e.message })) if (r && r.ok) { tickets.push({ issue: r.issue, subject: r.subject, queue: t.queue || '' }) if (t.queue && QUEUES.includes(t.queue)) { try { await notifyQueueLabel(t.queue, conv, r.subject) } catch (e) { /* */ } } // chaque équipe notifiée de SON ticket } } if (tickets.length) { addMessage(conv, { from: 'system', text: `✂️ Conversation scindée en ${tickets.length} ticket(s) : ${tickets.map(x => x.issue).join(', ')}` }); saveToDisk() } return { ok: tickets.length > 0, tickets, count: tickets.length } } // ── MERGE : fusionne la conversation SOURCE dans la conversation CIBLE (doublons) ── // Les messages/notes/tickets de la source sont rapatriés dans la cible ; la source est marquée fusionnée (masquée). function mergeConversations (targetToken, sourceToken) { if (targetToken === sourceToken) return { ok: false, error: 'Identiques' } const target = getConversation(targetToken); const source = getConversation(sourceToken) if (!target) return { ok: false, error: 'Cible introuvable' } if (!source) return { ok: false, error: 'Source introuvable' } const srcLabel = source.customerName || source.email || source.phone || source.token target.messages = (target.messages || []).concat((source.messages || []).filter(m => m.from !== 'system')) .sort((a, b) => String(a.ts).localeCompare(String(b.ts))) if (source.notes && source.notes.length) target.notes = (target.notes || []).concat(source.notes) if (source.linkedTickets && source.linkedTickets.length) target.linkedTickets = (target.linkedTickets || []).concat(source.linkedTickets) if (!target.customer && source.customer) { target.customer = source.customer; target.customerName = target.customerName || source.customerName } if (!target.email && source.email) target.email = source.email if (!target.threadId && source.threadId) target.threadId = source.threadId if (Array.isArray(source._emailMsgIds)) target._emailMsgIds = (target._emailMsgIds || []).concat(source._emailMsgIds) addMessage(target, { from: 'system', text: `🔗 Conversation « ${srcLabel} » (#${source.token}) fusionnée ici` }) source.status = 'merged'; source.mergedInto = target.token; source.noise = true // disparaît de l'Inbox saveToDisk() sse.broadcast('conversations', 'conv-merged', { target: target.token, source: source.token }) log(`Merge ${source.token} → ${target.token}`) return { ok: true, target: target.token, source: source.token, messages: target.messages.length } } // ── COMMANDE EN LANGAGE NATUREL (copilote) ── // Gemini interprète la commande de l'agent + le contexte → exécute les actions SÛRES (file, claim, note, ticket) // et RÉDIGE un brouillon de réponse (JAMAIS auto-envoyé — l'agent valide d'un clic). Réutilise gmail/createTicket/claim. async function nlCommand (token, command, agentEmail) { const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' } if (!cfg.AI_API_KEY) return { ok: false, error: 'IA indisponible' } if (!String(command || '').trim()) return { ok: false, error: 'commande vide' } const msgs = (conv.messages || []).slice(-8).map(m => (m.from === 'customer' ? 'CLIENT' : (m.via === 'ai' ? 'IA' : 'AGENT')) + ': ' + String(m.text || '').slice(0, 300)).join('\n') const ctx = `Conversation #${conv.token} · canal ${conv.channel || 'sms'} · client ${conv.customerName || '?'} (${conv.customer ? 'connu' : 'inconnu'}) · file actuelle ${conv.queue || '(aucune)'} · assigné ${conv.assignee || '(personne)'}\nDerniers messages :\n${msgs}` const sys = 'Tu assistes un agent support TARGO/Gigafibre (fournisseur Internet). À partir de la COMMANDE de l\'agent et du CONTEXTE, renvoie UNIQUEMENT du JSON (aucun texte autour) : {"queue":"Facturations|Service à la clientèle|Supports|Technicien|null","claim":true|false,"note":"note interne en français ou null","reply":"brouillon de réponse au CLIENT en français, ton professionnel, ou null","create_ticket":{"title":"...","category":"Support|Facturation|Vente|Installation|Commercial|Autre","priority":"Low|Medium|High|Urgent"} ou null,"summary":"une phrase résumant ce que tu as fait"}. N\'invente jamais d\'information (montants, dates, identités). Mets "reply" SEULEMENT si la commande demande explicitement d\'écrire/répondre au client.' let out try { out = await require('./ai').chat({ task: 'nl', maxTokens: 700, temperature: 0.2, messages: [{ role: 'system', content: sys }, { role: 'user', content: 'CONTEXTE :\n' + ctx + '\n\nCOMMANDE : ' + command }] }) } catch (e) { return { ok: false, error: 'IA : ' + e.message } } let plan; try { plan = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0]) } catch (e) { return { ok: false, error: 'réponse IA illisible' } } const applied = [] if (plan.queue && QUEUES.includes(plan.queue)) { conv.queue = plan.queue; applied.push('File → ' + plan.queue); sse.broadcast('conversations', 'conv-queue', { token, queue: conv.queue }) } if (plan.claim) { conv.assignee = agentEmail || 'agent'; conv.assigneeAt = new Date().toISOString(); applied.push('Assigné à ' + (agentEmail || 'vous')); sse.broadcast('conversations', 'conv-claimed', { token, assignee: conv.assignee }) } if (plan.note) { conv.notes = conv.notes || []; const note = { id: crypto.randomUUID(), agent: agentEmail || 'IA', text: String(plan.note), ts: new Date().toISOString() }; conv.notes.push(note); applied.push('Note interne ajoutée'); sse.broadcast('conversations', 'conv-note', { token, note }) } let ticket = null if (plan.create_ticket && plan.create_ticket.title) { const r = await createTicketFromConversation(token, { title: plan.create_ticket.title, category: plan.create_ticket.category, priority: plan.create_ticket.priority }).catch(e => ({ ok: false })); if (r && r.ok) { ticket = r.issue; applied.push('Ticket ' + r.issue + ' créé') } } if (applied.length) { conv.lastHumanDate = todayET(); saveToDisk() } return { ok: true, applied, draft_reply: plan.reply || null, summary: plan.summary || '', ticket } } const TK = /^\/conversations\/([A-Za-z0-9_-]{5,10})$/ const TK_SUB = /^\/conversations\/([A-Za-z0-9_-]{5,10})\// // RÉSUMÉ IA d'un fil (style « Summarise this email » de Gmail) — pour l'agent, pas envoyé au client. PII → routé 'triage'. async function summarizeConversation (token) { const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' } const who = m => m.from === 'agent' ? (m.via === 'ai' ? 'IA' : 'Agent') : (conv.customerName || conv.email || 'Client') const msgs = (conv.messages || []).map(m => { const body = String(m.text || '').replace(/^✉️[^\n]*\n+/, '').replace(/<[^>]+>/g, ' ').replace(/ /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, '
') } } async function handle (req, res, method, p, url) { // INBOX UNIFIÉ — tickets (Issues ERPNext) ACTIFS RÉCENTS (file agent), fusionnés avec les conversations côté front. // ⚠️ 897 Issues Open existent (backlog legacy importé, jamais retouché) → on filtre sur l'ACTIVITÉ RÉCENTE (modifié < N j) // pour ne montrer que la vraie file (les tickets créés depuis conversations apparaissent ici). ?days= et ?all=1 (backlog) en option. if (p === '/conversations/inbox-tickets' && method === 'GET') { try { const all = url && url.searchParams && url.searchParams.get('all') === '1' const days = Math.max(1, Math.min(365, Number(url && url.searchParams && url.searchParams.get('days')) || 45)) const filters = [['status', 'in', ['Open', 'Replied']]] if (!all) { const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); filters.push(['modified', '>', since]) } const rows = await erp.list('Issue', { filters, fields: ['name', 'subject', 'customer', 'status', 'priority', 'modified', 'creation', '_assign', 'owner'], limit: 200, orderBy: 'modified desc' }) // _assign Frappe = chaîne JSON '["a@b.com",…]' → tableau d'emails (pour le filtre « assignés à moi » côté Ops). Lien conv↔ticket : token du fil qui a créé/rattaché le ticket (clic = ouvrir le fil). const tok = {}; for (const c of conversations.values()) { for (const t of (c.linkedTickets || [])) { if (t && t.name && !tok[t.name]) tok[t.name] = c.token } } const tickets = (rows || []).map(r => { let asn = []; try { asn = JSON.parse(r._assign || '[]') } catch (e) { asn = [] } return { ...r, assignees: Array.isArray(asn) ? asn : [], convToken: tok[r.name] || null } }) return json(res, 200, { tickets, recent_days: all ? null : days }) } catch (e) { log('inbox-tickets error:', e.message); return json(res, 200, { tickets: [], error: e.message }) } } // TRIAGE IA (Phase 3) — aperçu : classe un courriel (client connu ? type ? suggérer ticket ?). Réutilisé par le poller mail. if (p === '/conversations/triage-preview' && method === 'POST') { try { const b = await parseBody(req); const r = await require('./inbox-triage').classifyEmail(b || {}); return json(res, 200, r) } catch (e) { return json(res, 500, { error: e.message }) } } // PIPELINE de leads : board (colonnes par étape) des conversations en pipeline. if (p === '/conversations/pipeline-board' && method === 'GET') { const cols = {}; PIPELINE_STAGES.forEach(s => { cols[s] = [] }) for (const conv of conversations.values()) { if (!conv.pipeline || !conv.pipeline.stage) continue if (isExpired(conv)) continue const st = PIPELINE_STAGES.includes(conv.pipeline.stage) ? conv.pipeline.stage : 'Nouveau' cols[st].push({ token: conv.token, customerName: conv.customerName, contact: conv.email || conv.phone || '', subject: conv.subject || '', value: conv.pipeline.value || 0, queue: conv.queue || '', assignee: conv.assignee || null, channel: conv.channel || 'sms', is_customer: !!(conv.triage && conv.triage.is_customer), lastActivity: conv.lastActivity }) } for (const s in cols) cols[s].sort((a, b) => String(b.lastActivity).localeCompare(String(a.lastActivity))) return json(res, 200, { stages: PIPELINE_STAGES, columns: cols }) } // Appartenance agent ↔ file (config éditable). GET = lire ; POST {members:{...}} = écrire. if (p === '/conversations/queue-members' && (method === 'GET' || method === 'POST')) { if (method === 'GET') return json(res, 200, { members: loadQueueMembers(), queues: QUEUES, labels: loadQueueLabels(), issueTypeQueue: ISSUE_TYPE_QUEUE, visibility: loadVisibility() }) try { const b = await parseBody(req) if (b.members) writeJsonFile('/app/data/queue_members.json', b.members) if (b.labels) writeJsonFile('/app/data/queue_labels.json', b.labels) // libellés d'affichage (nom réel = clé inchangée) if (b.visibility) writeJsonFile('/app/data/queue_visibility.json', b.visibility) // mode de visibilité par personne (personnel/department/tags) return json(res, 200, { ok: true, members: b.members || loadQueueMembers(), labels: b.labels || loadQueueLabels(), visibility: b.visibility || loadVisibility() }) } catch (e) { return json(res, 500, { error: e.message }) } } // Suivi PERSO d'un ticket (watch) : GET → mes tickets suivis ; POST {ticket, follow} → (dé)suivre. Toujours visibles dans « Mes tickets ». // Rétro-compatible : délègue au store généralisé (doctype « Issue »). if (p === '/conversations/ticket-follow' && (method === 'GET' || method === 'POST')) { const email = String(req.headers['x-authentik-email'] || '').toLowerCase() const all = loadFollows() if (method === 'GET') return json(res, 200, { follows: followsFor(all, email, 'Issue') }) try { const b = await parseBody(req); const t = String(b.ticket || '').trim() if (!email || !t) return json(res, 400, { error: 'email + ticket requis' }) const arr = new Set(followsFor(all, email, 'Issue')) if (b.follow === false) arr.delete(t); else arr.add(t) ;(all[email] || (all[email] = {})).Issue = [...arr]; saveFollows(all) return json(res, 200, { ok: true, follows: all[email].Issue, following: arr.has(t) }) } catch (e) { return json(res, 500, { error: e.message }) } } // Suivi GÉNÉRALISÉ (tout doctype) : GET ?doctype=X → mes noms suivis ; POST {doctype, name, follow} → (dé)suivre. // Ex. suivre un Dispatch Job, un Customer… au-delà des tickets (Issue). Le suivi « Issue » reste synchronisé avec ticket-follow. if (p === '/conversations/follow' && (method === 'GET' || method === 'POST')) { const email = String(req.headers['x-authentik-email'] || '').toLowerCase() const all = loadFollows() if (method === 'GET') { const dt = String(url.searchParams.get('doctype') || '').trim() if (dt) return json(res, 200, { doctype: dt, follows: followsFor(all, email, dt) }) return json(res, 200, { follows: (email && all[email]) || {} }) // tous doctypes → { doctype: [names] } } try { const b = await parseBody(req); const dt = String(b.doctype || '').trim(); const nm = String(b.name || '').trim() // Cible = courriel explicite (abonner N'IMPORTE QUEL utilisateur — champ d'assignation « # follower ») OU, à défaut, l'identité de l'appelant. const target = String(b.email || email || '').toLowerCase() if (!target || !dt || !nm) return json(res, 400, { error: 'email + doctype + name requis' }) const arr = new Set(followsFor(all, target, dt)) if (b.follow === false) arr.delete(nm); else arr.add(nm) ;(all[target] || (all[target] = {}))[dt] = [...arr]; saveFollows(all) return json(res, 200, { ok: true, doctype: dt, name: nm, email: target, follows: all[target][dt], following: arr.has(nm) }) } catch (e) { return json(res, 500, { error: e.message }) } } // Qui suit ce doc : GET ?doctype=X&name=Y → { followers: [courriels] }. Alimente les pastilles « # » du champ d'assignation. if (p === '/conversations/followers' && method === 'GET') { const dt = String(url.searchParams.get('doctype') || '').trim() const nm = String(url.searchParams.get('name') || '').trim() if (!dt || !nm) return json(res, 400, { error: 'doctype + name requis' }) return json(res, 200, { doctype: dt, name: nm, followers: followersOf(loadFollows(), dt, nm) }) } // Règles d'inbox (« Filtrer comme ceci ») : masquer/afficher par expéditeur ou sujet — l'agent choisit ce qui est masqué. if (p === '/conversations/inbox-rules' && (method === 'GET' || method === 'POST')) { if (method === 'GET') return json(res, 200, { rules: loadInboxRules() }) try { const b = await parseBody(req) writeJsonFile('/app/data/inbox_rules.json', Array.isArray(b.rules) ? b.rules : []) // Rétro-application aux conversations EXISTANTES : masquer/afficher tout de suite ce qui correspond (sinon les règles n'agiraient qu'au prochain courriel). let changed = 0 for (const c of conversations.values()) { if (c.channel !== 'email') continue const r = matchInboxRule(c.email, c.lastSubject || c.subject) if (r === 'mask' && !c.noise) { c.noise = true; changed++ } else if (r === 'allow' && c.noise) { c.noise = false; changed++ } } if (changed) saveToDisk() return json(res, 200, { ok: true, rules: loadInboxRules(), retro: changed }) } catch (e) { return json(res, 500, { error: e.message }) } } // Réponses pré-écrites (canned) : liste + sauvegarde (remplacement complet du tableau, comme queue-members). if (p === '/conversations/canned' && (method === 'GET' || method === 'POST')) { if (method === 'GET') return json(res, 200, { responses: loadCanned() }) try { const b = await parseBody(req); const list = Array.isArray(b) ? b : (b.responses || []); const ok = saveCanned(list); return json(res, ok ? 200 : 500, { ok, responses: list }) } catch (e) { return json(res, 500, { error: e.message }) } } // NOUVEAU courriel SORTANT depuis l'Inbox (agent). Gaté (Authentik/proxy). if (p === '/conversations/email-new' && method === 'POST') { try { const b = await parseBody(req); const r = await sendNewEmail({ ...(b || {}), agentEmail: req.headers['x-authentik-email'] || (b && b.agentEmail) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) } } // ALLER-RETOUR COURRIEL depuis un TICKET (Issue). Gaté (Authentik/proxy). La réponse du client re-thread → Communication sur l'Issue. if (p === '/conversations/ticket-email' && method === 'POST') { try { const b = await parseBody(req); const r = await emailTicket({ ...(b || {}), agent: req.headers['x-authentik-email'] || (b && b.agent) || '', agentName: (b && b.agentName) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) } } // INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik). if (p === '/conversations/email-ingest' && method === 'POST') { const tok = req.headers['x-mail-token'] || '' const want = mailIngestToken() if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' }) try { const b = await parseBody(req); const r = await ingestEmail(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) } } // INGESTION CANAL (web chat nouveau site / 3CX / Facebook) — webhook externe. Protégé par X-Mail-Token (même jeton que l'ingest courriel). if (p === '/conversations/channel-ingest' && method === 'POST') { const tok = req.headers['x-mail-token'] || '' const want = mailIngestToken() if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' }) try { const b = await parseBody(req); const r = await channelIngest(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) } } if (p === '/conversations' && method === 'POST') { const body = await parseBody(req) const { phone, customer, customerName, subject, message, media } = body if (!phone) return json(res, 400, { error: 'Missing phone' }) const agentEmail = req.headers['x-authentik-email'] || null const conv = createConversation({ phone, customer, customerName, agentEmail, subject: subject || (message ? String(message).slice(0, 60) : '') }) conv.lastHumanDate = todayET() // fil INITIÉ depuis notre interface (humain) → l'IA ne répond pas le même jour const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}` const { sendSmsInternal } = require('./twilio') let outText if (message || media) { // Texto chat : on envoie LE TEXTE DE L'AGENT (+ image MMS), pas un gabarit de lien. outText = String(message || '') addMessage(conv, { from: 'agent', text: outText, via: 'sms', type: media ? 'image' : 'text', media: media || '' }) try { await sendSmsInternal(phone, outText, customer, media || ''); conv.smsCount++; saveToDisk() } catch (e) { log('SMS/MMS send failed:', e.message) } } else { outText = subject ? `Gigafibre — ${subject}\n\nContinuez la conversation ici:\n${link}` : `Gigafibre — Vous avez un nouveau message.\n\nRépondre ici:\n${link}` try { await sendSmsInternal(phone, outText, customer); conv.smsCount++; saveToDisk() } catch (e) { log('Failed to send link SMS:', e.message) } } if (customer) { await createCommunication({ communication_type: 'Communication', communication_medium: 'SMS', sent_or_received: 'Sent', sender: 'sms@gigafibre.ca', sender_full_name: 'Targo Ops', phone_no: phone, content: outText + (media ? '\n[image jointe]' : ''), subject: 'SMS sortant', reference_doctype: 'Customer', reference_name: customer, status: 'Linked', }).catch(() => {}) } return json(res, 201, { ok: true, token: conv.token, link }) } if (p === '/conversations' && method === 'GET') { const _t0 = Date.now(); const _all = url.searchParams.get('all') === '1' const result = listConversations({ includeAll: _all, lean: true }) // liste = résumé léger ; messages chargés à l'ouverture // Pagination OPTIONNELLE (infinite-scroll Boîte) : ?limit=&offset= sur les DISCUSSIONS (déjà triées lastActivity desc). // Additif/rétrocompatible : sans `limit`, comportement inchangé (liste complète). Le payload lourd = discussions[].messages → on n'en sert qu'une page. const limit = parseInt(url.searchParams.get('limit'), 10) if (Number.isFinite(limit) && limit > 0) { const offset = Math.max(0, parseInt(url.searchParams.get('offset'), 10) || 0) const all = result.discussions || [] const page = all.slice(offset, offset + limit) log(`conversations list: page ${offset}–${offset + page.length}/${all.length} fils · ${Date.now() - _t0}ms (all=${_all})`) // `conversations` (plat, LÉGER, sans corps de messages) renvoyé en entier pour les lookups/compteurs globaux ; seules les discussions sont paginées. return json(res, 200, { discussions: page, conversations: result.conversations, total: all.length, hasMore: offset + page.length < all.length, offset, limit }) } const _msgs = (result.discussions || []).reduce((s, d) => s + ((d.messages && d.messages.length) || 0), 0) log(`conversations list: ${(result.discussions || []).length} fils · ${(result.conversations || []).length} convs · ${_msgs} msgs · ${Date.now() - _t0}ms (all=${_all})`) // mesure C return json(res, 200, result) } // GET /conversations/for-customer?customer=&email=&phone= — fils (email+SMS) d'UN client pour la fiche (timeline customer-360) if (p === '/conversations/for-customer' && method === 'GET') { const cust = (url.searchParams.get('customer') || '').trim() const email = (url.searchParams.get('email') || '').trim().toLowerCase() const phone = (url.searchParams.get('phone') || '').replace(/\D/g, '').slice(-10) if (!cust && !email && !phone) return json(res, 200, { discussions: [] }) const { discussions } = listConversations({ includeAll: true }) const mine = discussions.filter(d => { // Lien EXPLICITE à ce client = toujours inclus (source de vérité). if (cust && d.customer === cust) return true // Déjà lié à un AUTRE client = jamais (sinon fuite inter-fiches via email/tél partagé ou interne). if (cust && d.customer && d.customer !== cust) return false // Conversation NON liée : repli par email/tél — mais JAMAIS via une de NOS adresses (staff/support @targo.ca…), // sinon les fils internes/partagés remontent sur toutes les fiches. const de = String(d.email || '').toLowerCase() if (email && de === email && !OWN_DOMAINS.test(de)) return true const dp = String(d.phone || '').replace(/\D/g, '').slice(-10) if (phone && dp.length === 10 && dp === phone) return true return false }) return json(res, 200, { discussions: mine }) } // GET /conversations/leaderboard?from=YYYY-MM-DD&to=YYYY-MM-DD — classement des agents Inbox (reconnaissance / motivation). // « pris » = conversations dont l'agent est assigné (claim, via assigneeAt) ; « réponses » = messages agent horodatés à son nom (stamping msg.agent — historique avant stamping = unattributed). // Identités d'expédition pour le sélecteur « De : » (défaut groupe + alias vérifiés). if (p === '/conversations/senders' && method === 'GET') { return json(res, 200, { default: DEFAULT_SENDER, identities: SENDER_IDENTITIES.map(i => ({ name: i.name || '', address: i.address, from: identityToFrom(i) })) }) } // Rafraîchir À LA DEMANDE : force un pull Gmail immédiat (≠ fetchList qui ne lit que l'état hub déjà ingéré). if (p === '/conversations/poll-now' && method === 'POST') { try { const r = await require('./gmail').poll(); return json(res, 200, { ok: true, scanned: (r && r.scanned) || 0, ingested: (r && r.ingested) || 0 }) } catch (e) { return json(res, 200, { ok: false, error: e.message }) } } // Préférences de notification PAR UTILISATEUR (clé = courriel agent) : activer/désactiver chaque feed (cloche). if (p === '/conversations/notif-prefs' && (method === 'GET' || method === 'PUT')) { const NOTIF_DEFAULTS = { sms: true, webchat: true, '3cx': true, call: true, facebook: true, email: false, ratings: true } const email = String(req.headers['x-authentik-email'] || '').toLowerCase() const all = readJsonFile('/app/data/notif_prefs.json', {}) if (method === 'PUT') { if (!email) return json(res, 400, { error: 'utilisateur inconnu' }) const b = await parseBody(req); const inp = (b && b.prefs) || {} const clean = {}; for (const k of Object.keys(NOTIF_DEFAULTS)) clean[k] = typeof inp[k] === 'boolean' ? inp[k] : NOTIF_DEFAULTS[k] all[email] = clean; writeJsonFile('/app/data/notif_prefs.json', all) return json(res, 200, { ok: true, prefs: clean }) } return json(res, 200, { prefs: { ...NOTIF_DEFAULTS, ...(all[email] || {}) }, defaults: NOTIF_DEFAULTS }) } // Abonnement PAR AGENT aux files + notif par file (self-service) : membership = queue_members.json ; notif = queue_notify.json. // GET → mes files + état ; POST {queue, subscribe?, notify?} → me (dés)abonner / (dé)activer la notif de CETTE file. if (p === '/conversations/my-queues' && (method === 'GET' || method === 'POST')) { const email = String(req.headers['x-authentik-email'] || '').toLowerCase() if (!email) return json(res, 400, { error: 'utilisateur inconnu' }) const members = loadQueueMembers() const notifyAll = readJsonFile('/app/data/queue_notify.json', {}) if (method === 'POST') { const b = await parseBody(req); const queue = String(b.queue || '') if (!QUEUES.includes(queue)) return json(res, 400, { error: 'file inconnue' }) if (typeof b.subscribe === 'boolean') { const arr = (members[queue] || []).filter(e => String(e).toLowerCase() !== email) if (b.subscribe) arr.push(email) members[queue] = arr; writeJsonFile('/app/data/queue_members.json', members) } if (typeof b.notify === 'boolean') { notifyAll[email] = notifyAll[email] || {}; notifyAll[email][queue] = b.notify; writeJsonFile('/app/data/queue_notify.json', notifyAll) } } const mine = QUEUES.filter(q => (members[q] || []).map(e => String(e).toLowerCase()).includes(email)) return json(res, 200, { queues: QUEUES, labels: loadQueueLabels(), mine, notify: notifyAll[email] || {} }) } if (p === '/conversations/leaderboard' && method === 'GET') { const to = url.searchParams.get('to') || new Date().toISOString().slice(0, 10) const from = url.searchParams.get('from') || new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10) const inRange = (iso) => { const d = String(iso || '').slice(0, 10); return !!d && d >= from && d <= to } const agents = {} const A = (k) => (agents[k] = agents[k] || { agent: k, handled: 0, replies: 0 }) let unattributed = 0 for (const [, conv] of conversations) { if (conv.assignee && (!conv.assigneeAt || inRange(conv.assigneeAt))) A(conv.assignee).handled++ for (const m of (conv.messages || [])) { if (m.from !== 'agent' || !inRange(m.ts)) continue if (m.agent) A(m.agent).replies++; else unattributed++ } } const board = Object.values(agents).sort((a, b) => (b.handled - a.handled) || (b.replies - a.replies)).slice(0, 20) return json(res, 200, { from, to, board, unattributed }) } // POST /conversations/backfill-senders — rétro-remplit msg.fromName/fromEmail des courriels // ingérés AVANT la capture per-message de l'en-tête From, en relisant Gmail. En-process // (mute l'état mémoire + saveToDisk → pas de course de fichier). Idempotent : ne touche // que les messages courriel SANS fromName. Mappe par msg.gmail_id, sinon par ordre via _gmailIds. if (p === '/conversations/backfill-senders' && method === 'POST') { const gmail = require('./gmail') const { extractEmail } = require('./inbox-triage') let patched = 0, convsTouched = 0 for (const [, conv] of conversations) { if (conv.channel !== 'email') continue const gids = conv._gmailIds || [] const emailMsgs = (conv.messages || []).filter(m => m.via === 'email' || m.html) let touched = false for (let i = 0; i < emailMsgs.length; i++) { const m = emailMsgs[i] if (m.fromName) continue const gid = m.gmail_id || gids[i] if (!gid) continue try { const gm = await gmail.getMessage(gid) const nm = gm && gm.from ? fromDisplayName(gm.from) : '' if (nm) { m.fromName = nm; m.fromEmail = extractEmail(gm.from); m.gmail_id = gid; patched++; touched = true } } catch (e) { /* message Gmail introuvable → ignorer */ } } if (touched) convsTouched++ } saveToDisk() return json(res, 200, { ok: true, patched, convsTouched }) } // Cherche les fiches Customer par téléphone → l'OPS propose de lier / de choisir parmi plusieurs. if (p === '/conversations/resolve' && method === 'GET') { const emailQ = url.searchParams.get('email') const matches = emailQ ? await lookupCustomersByEmail(emailQ, 8) : await lookupCustomersByPhone(url.searchParams.get('phone') || '', 8) return json(res, 200, { ok: true, count: matches.length, matches }) } // RECHERCHE FLOUE omnichannel (SQL pg_trgm) : nom client / courriel / sujet + corps des messages, typo-tolérante. // Repli MÉMOIRE (sous-chaîne) si Postgres est indisponible → la recherche fonctionne toujours. if (p === '/conversations/search' && method === 'GET') { const q = String(url.searchParams.get('q') || '').trim() if (!q) return json(res, 200, { ok: true, results: [], source: 'none' }) let results = await store.search(q, 30) if (results === null) { const ql = q.toLowerCase() const mem = [] for (const conv of conversations.values()) { if (isExpired(conv)) continue const direct = [conv.customerName, conv.email, conv.lastSubject || conv.subject].filter(Boolean).join(' ').toLowerCase().includes(ql) let snippet = null let hit = direct if (!hit) { const m = (conv.messages || []).find(x => String(x.text || '').toLowerCase().includes(ql)); if (m) { hit = true; snippet = String(m.text || '').replace(/\s+/g, ' ').slice(0, 140) } } if (hit) mem.push({ token: conv.token, customerName: conv.customerName, email: conv.email, subject: conv.lastSubject || conv.subject, channel: conv.channel, queue: conv.queue, status: conv.status, lastActivity: conv.lastActivity, score: 0, direct, snippet }) } mem.sort((a, b) => (Number(b.direct) - Number(a.direct)) || String(b.lastActivity || '').localeCompare(String(a.lastActivity || ''))) results = mem.slice(0, 30) } return json(res, 200, { ok: true, results, source: store.available() ? 'sql' : 'memory' }) } // Résout un NUMÉRO DE RÉFÉRENCE de ticket (ex. « 251382 » dans un courriel) → fiche client, via le Dispatch Job lié // (legacy_ticket_id = n° osTicket ; repli sur le nom du job qui contient « # »). Pour le sélecteur « Lier une fiche ». if (p === '/conversations/resolve-ref' && method === 'GET') { const ref = String(url.searchParams.get('ref') || '').replace(/\D/g, '') if (ref.length < 3) return json(res, 200, { ok: true, ref, matches: [] }) const out = []; const seen = new Set(); let ticket = null const push = (m, via, subject) => { if (m && m.name && !seen.has(m.name)) { seen.add(m.name); out.push({ name: m.name, customer_name: m.customer_name || m.name, email: m.email, phone: m.phone, address: m.address, via, subject }) } } try { // n° de référence = legacy_ticket_id du Dispatch Job (n° osTicket), ou présent dans le sujet « … · # » let jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', ref]], fields: ['name', 'customer', 'service_location', 'subject', 'address', 'legacy_detail', 'status'], limit: 5 }) if (!jobs || !jobs.length) jobs = await erp.list('Dispatch Job', { filters: [['subject', 'like', '%' + ref + '%']], fields: ['name', 'customer', 'service_location', 'subject', 'address', 'legacy_detail', 'status'], limit: 5 }) for (const j of (jobs || [])) { const tag = 'Ticket #' + ref + (j.status ? ' · ' + j.status : '') const det = String(j.legacy_detail || '') const email = (det.match(/Email\s*:\s*([^\s<]+@[^\s<]+)/i) || [])[1] const phone = (det.match(/(?:T[ée]l[ée]phone|Tel)\s*:\s*([\d().+\-\s]{7,})/i) || [])[1] const nom = (det.match(/Nom\s*:\s*(.+?)\s+(?:ID|Email|Adresse|T[ée]l)\s*:/i) || [])[1] const adr = (det.match(/Adresse\s*:\s*(.+?)\s+(?:T[ée]l[ée]phone|Tel|Installa|Service)/i) || [])[1] || j.address const ids = ((det.match(/\bID\s*:\s*([\d\s]+)/i) || [])[1] || '').trim().split(/\s+/).filter(x => x.length >= 3) // Identité du ticket (utile même si AUCUNE fiche ERPNext ne correspond — fréquent : client non encore synchronisé) if (!ticket) ticket = { ref, name: (nom || '').trim(), email: email || '', phone: (phone || '').replace(/\s+/g, ' ').trim(), address: (adr || '').trim(), subject: j.subject || '', status: j.status || '' } // Résolution de la FICHE : client lié au job → courriel → téléphone → n° de compte → adresse (service location) if (j.customer) { let cn = j.customer; try { const c = (await erp.list('Customer', { filters: [['name', '=', j.customer]], fields: ['name', 'customer_name'], limit: 1 }))[0]; cn = (c && c.customer_name) || j.customer } catch (e) { /* */ } push({ name: j.customer, customer_name: cn }, tag, j.subject); continue } let got = 0 if (email) { for (const m of await lookupCustomersByEmail(email, 5)) { push(m, tag + ' · courriel', j.subject); got++ } } if (!got && phone) { for (const m of await lookupCustomersByPhone(phone, 5)) { push(m, tag + ' · téléphone', j.subject); got++ } } if (!got && ids.length) { for (const id of ids.slice(0, 4)) { const cs = await erp.list('Customer', { filters: [['legacy_account_id', '=', id]], fields: ['name', 'customer_name'], limit: 2 }); for (const c of (cs || [])) { push({ name: c.name, customer_name: c.customer_name }, tag + ' · n° de compte', j.subject); got++ } } } if (!got && j.service_location) { try { const sl = (await erp.list('Service Location', { filters: [['name', '=', j.service_location]], fields: ['customer'], limit: 1 }))[0]; if (sl && sl.customer) { const c = (await erp.list('Customer', { filters: [['name', '=', sl.customer]], fields: ['name', 'customer_name'], limit: 1 }))[0]; push({ name: sl.customer, customer_name: (c && c.customer_name) || sl.customer }, tag + ' · adresse', j.subject) } } catch (e) { /* */ } } } } catch (e) { log('resolve-ref error: ' + e.message) } return json(res, 200, { ok: true, ref, matches: out, ticket }) } if (p === '/conversations/archive' && method === 'POST') { const { tokens } = await parseBody(req) if (!tokens || !tokens.length) return json(res, 400, { error: 'Missing tokens array' }) try { const result = await archiveDiscussion(tokens) if (!result) return json(res, 404, { error: 'No messages found' }) sse.broadcast('conversations', 'conv-deleted', { archived: true, issue: result.issue }) return json(res, 200, { ok: true, ...result }) } catch (e) { return json(res, 500, { error: 'Archive failed: ' + e.message }) } } if (p === '/conversations/bulk' && method === 'DELETE') { const { discussions: discs } = await parseBody(req) if (!discs || !discs.length) return json(res, 400, { error: 'Missing discussions array' }) let totalDeleted = 0 for (const d of discs) { totalDeleted += d.tokens ? deleteDiscussionByTokens(d.tokens) : (d.phone && d.date ? deleteDiscussion(d.phone, d.date) : 0) } sse.broadcast('conversations', 'conv-deleted', { bulk: true, count: totalDeleted }) return json(res, 200, { ok: true, deleted: totalDeleted }) } if (p === '/conversations/discussion' && method === 'DELETE') { const { phone, date } = await parseBody(req) if (!phone || !date) return json(res, 400, { error: 'Missing phone or date' }) const count = deleteDiscussion(phone, date) log(`Deleted discussion: ${phone} on ${date} (${count} conversations)`) sse.broadcast('conversations', 'conv-deleted', { phone, date, count }) return json(res, 200, { ok: true, deleted: count }) } // Liste des agents assignables (roster DÉCOUPLÉ des notifs de file). AVANT le match TK (« agents » ≈ un token). // Source : /app/data/agents.json (roster canonique) ; repli = union des membres de file. Découplé car les listes // de notif peuvent être vidées (anti-spam) sans retirer l'agent du choix d'assignation. if (p === '/conversations/agents' && method === 'GET') { let list = readJsonFile('/app/data/agents.json', null) if (!Array.isArray(list) || !list.length) { const mem = loadQueueMembers(); const set = new Set(); for (const k of Object.keys(mem)) for (const e of (mem[k] || [])) set.add(e); list = [...set] } const out = [...new Set((list || []).map(e => String(e).toLowerCase()).filter(e => /.+@.+\..+/.test(e)))].sort() // Noms RÉELS (tabUser) → afficher/chercher « Michel Blais » et pas seulement le préfixe courriel « michel » dans l'assignation/@mention. Best-effort. const names = {} try { let pool = null; try { pool = require('./address-db').pool() } catch (e) { /* address-db indispo */ } if (pool && out.length) { const r = await pool.query('SELECT name, full_name FROM "tabUser" WHERE lower(name) = ANY($1)', [out]); for (const row of r.rows) if (row.full_name) names[String(row.name).toLowerCase()] = row.full_name } } catch (e) { /* noms best-effort — l'assignation marche sans */ } return json(res, 200, { agents: out, names }) } let m if ((m = p.match(TK)) && method === 'GET') { const conv = getConversation(m[1]) if (!conv) return json(res, 404, { error: 'Conversation not found or expired' }) return json(res, 200, { token: conv.token, customerName: conv.customerName, subject: conv.subject, status: conv.status, messages: conv.messages, createdAt: conv.createdAt, customer: conv.customer || null, phone: conv.phone || null, channel: conv.channel || 'sms', email: conv.email || null, queue: conv.queue || '', assignee: conv.assignee || null, assigneeAt: conv.assigneeAt || null, priority: conv.priority || '', assistants: conv.assistants || [], ticketState: conv.ticketState || (conv.status === 'closed' ? 'closed' : 'open'), pendingUntil: conv.pendingUntil || null, notes: conv.notes || [], linkedTickets: conv.linkedTickets || [], links: conv.links || [], suggestedTicket: conv.suggestedTicket || null, pipeline: conv.pipeline || null, pipelineStages: PIPELINE_STAGES, triage: conv.triage || null, gmailReplyUrl: gmailReplyUrl(conv), queues: QUEUES, readBy: conv.readBy || {}, draft: conv.draft || null, vapidPublicKey: cfg.VAPID_PUBLIC_KEY || null, }) } if ((m = p.match(TK)) && method === 'DELETE') { const existed = deleteConversation(m[1], { trashGmail: true }) // suppression explicite → courriels liés à la corbeille Gmail (réversible 30 j) if (!existed) return json(res, 404, { error: 'Conversation not found' }) log(`Deleted conversation: ${m[1]}`) sse.broadcast('conversations', 'conv-deleted', { token: m[1] }) return json(res, 200, { ok: true }) } if ((m = p.match(TK_SUB))) { const sub = p.slice(p.indexOf(m[1]) + m[1].length + 1) const token = m[1] // Créer un TICKET (ERPNext Issue OUVERT) depuis la conversation — la conversation RESTE (≠ archive). if (sub === 'ticket' && method === 'POST') { const b = await parseBody(req) const r = await createTicketFromConversation(token, b) return json(res, r.ok ? 200 : 500, r) } // SPLIT — détecte les sujets distincts (aperçu, ne crée rien) puis crée 1 ticket par sujet sur confirmation. if (sub === 'split-preview' && method === 'GET') { const r = await detectSplitTopics(token) return json(res, r.ok ? 200 : 400, r) } if (sub === 'split' && method === 'POST') { const b = await parseBody(req) const r = await splitConversation(token, { topics: b.topics }) return json(res, r.ok ? 200 : 400, r) } // MERGE — fusionne une conversation SOURCE (body.from) dans CELLE-CI (cible = token de l'URL). if (sub === 'merge' && method === 'POST') { const b = await parseBody(req) const r = mergeConversations(token, String(b.from || b.source || '')) return json(res, r.ok ? 200 : 400, r) } // RE-SYNCHRONISER ce fil depuis Gmail — récupère les réponses envoyées hors hub + les courriels manqués (panne / sortis de la boîte). if (sub === 'resync' && method === 'POST') { const r = await resyncThread(token) return json(res, r.ok ? 200 : 400, r) } // PREUVE DE PAIEMENT — OCR d'une image (capture client) → affiche montant/date/réf (sans écrire en facturation). if (sub === 'payment-proof' && method === 'POST') { const b = await parseBody(req) const r = await analyzePaymentProof(token, b) return json(res, r.ok ? 200 : 400, r) } // Pièces jointes (PDF/JPG) du fil — pour le trombone. Lues à la demande via Gmail puis mises en cache sur le message. if (sub === 'attachments' && method === 'GET') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const map = {}; let fetched = false for (const m of (conv.messages || [])) { if (!m.gmail_id) continue if (m._att === undefined) { try { const gm = await gmail.getMessage(m.gmail_id); m._att = (gm.attachments || []).map(a => ({ filename: a.filename, mimeType: a.mimeType, attachmentId: a.attachmentId, size: a.size })); fetched = true } catch (e) { m._att = [] } } if (m._att && m._att.length) map[m.id] = m._att.map(a => ({ ...a, gmail_id: m.gmail_id })) } if (fetched) saveToDisk() return json(res, 200, { attachments: map }) } // Sert une pièce jointe en ligne (clic trombone / source pour la Vision). mime/name fournis par le client (issus de la liste). if (sub === 'attachment' && method === 'GET') { const gid = url.searchParams.get('gmail_id') || ''; const att = url.searchParams.get('att') || '' const mime = url.searchParams.get('mime') || 'application/octet-stream' const fname = (url.searchParams.get('name') || 'document').replace(/[^\w.\- ]/g, '_') if (!gid || !att) return json(res, 400, { error: 'gmail_id + att requis' }) try { const buf = Buffer.from(String(await gmail.getAttachment(gid, att) || '').replace(/-/g, '+').replace(/_/g, '/'), 'base64') res.writeHead(200, { 'Content-Type': mime, 'Content-Disposition': 'inline; filename="' + fname + '"', 'Cache-Control': 'private, max-age=300' }) return res.end(buf) } catch (e) { return json(res, 502, { error: 'Gmail: ' + e.message }) } } // RÉSUMÉ IA du fil (Gemini) — « Summarise this email » de Gmail, pour l'agent. if (sub === 'summarize' && (method === 'POST' || method === 'GET')) { const r = await summarizeConversation(token) return json(res, r.ok ? 200 : 400, r) } // #2 DISPONIBILITÉ : adresse (IA) → fibre (RQA) → brouillon de réponse de vente (l'agent valide avant envoi). if (sub === 'serviceability' && method === 'POST') { const b = await parseBody(req) const r = await serviceabilityDraft(token, b) return json(res, r.ok ? 200 : 400, r) } // ── Coordination shared-inbox : claim / release / file / note interne ── if (sub === 'claim' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const agent = req.headers['x-authentik-email'] || ((await parseBody(req)).agent) || 'agent' conv.assignee = agent; conv.assigneeAt = new Date().toISOString(); conv.lastHumanDate = todayET(); saveToDisk() sse.broadcast('conversations', 'conv-claimed', { token, assignee: agent }) // collision : les autres voient « pris par X » en direct return json(res, 200, { ok: true, assignee: agent }) } if (sub === 'release' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) conv.assignee = null; conv.assigneeAt = null; saveToDisk() sse.broadcast('conversations', 'conv-claimed', { token, assignee: null }) return json(res, 200, { ok: true }) } // Assignation DIRECTE (agent précis) + ASSISTANTS (liste d'emails, affichés en chips) — modèle assign_to + participants de F. if (sub === 'assign' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const b = await parseBody(req) if (b.assignee !== undefined) { conv.assignee = b.assignee ? String(b.assignee).toLowerCase() : null; conv.assigneeAt = conv.assignee ? new Date().toISOString() : null } if (Array.isArray(b.assistants)) conv.assistants = [...new Set(b.assistants.map(e => String(e).toLowerCase()).filter(e => /.+@.+\..+/.test(e) && e !== conv.assignee))] conv.lastHumanDate = todayET(); saveToDisk() sse.broadcast('conversations', 'conv-claimed', { token, assignee: conv.assignee, assistants: conv.assistants || [] }) return json(res, 200, { ok: true, assignee: conv.assignee, assistants: conv.assistants || [] }) } // ÉTAT « ticket » : open | pending (avec date d'attente) | closed. Reflété sur le ticket lié (Open/On Hold/Closed + rappel) s'il existe. if (sub === 'state' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const b = await parseBody(req) const st = ['open', 'pending', 'closed'].includes(b.state) ? b.state : 'open' conv.ticketState = st conv.pendingUntil = (st === 'pending' && b.pendingUntil) ? String(b.pendingUntil).slice(0, 10) : null conv.status = (st === 'closed') ? 'closed' : 'active' // fermé = quitte la file active ; ouvert/attente = actif conv.lastHumanDate = todayET(); saveToDisk() if (conv.linkedTickets && conv.linkedTickets.length) { // reflète sur le ticket ERPNext lié const issue = conv.linkedTickets[0].name const data = { status: st === 'closed' ? 'Closed' : st === 'pending' ? 'On Hold' : 'Open' } if (st === 'pending' && conv.pendingUntil) data.custom_reminder_date = conv.pendingUntil try { await require('./erp').update('Issue', issue, data) } catch (e) { log('state→ticket ' + issue + ': ' + e.message) } } sse.broadcast('conversations', 'conv-state', { token, ticketState: st, pendingUntil: conv.pendingUntil, status: conv.status }) return json(res, 200, { ok: true, ticketState: st, pendingUntil: conv.pendingUntil, status: conv.status }) } // SPAM → marque le(s) courriel(s) SPAM côté Gmail (Gmail apprend + sort de la boîte) puis retire la conversation de l'Inbox. if (sub === 'spam' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) let marked = 0 if (conv.channel === 'email') { const gmail = require('./gmail') const ids = (conv._gmailIds && conv._gmailIds.length) ? conv._gmailIds : (conv.lastGmailId ? [conv.lastGmailId] : []) for (const id of ids) { try { await gmail.markSpam(id); marked++ } catch (e) { log('markSpam ' + id + ': ' + e.message) } } } conversations.delete(token); store.remove(token).catch(() => {}); saveToDisk() // SPAM côté Gmail = le dossier Spam EST l'archive ; on ne met PAS à la corbeille sse.broadcast('conversations', 'conv-deleted', { token, spam: true }) log(`Conv ${token} → SPAM (${marked} courriel(s) marqués) + retirée de l'Inbox`) return json(res, 200, { ok: true, spammed: marked }) } if (sub === 'queue' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const b = await parseBody(req); conv.queue = String(b.queue || ''); saveToDisk() sse.broadcast('conversations', 'conv-queue', { token, queue: conv.queue }) if (conv.queue && !conv.assignee) notifyQueue(conv).catch(() => {}) // routage manuel → pousse aux membres de la file return json(res, 200, { ok: true, queue: conv.queue }) } if (sub === 'note' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const b = await parseBody(req); const text = String(b.text || '').trim(); if (!text) return json(res, 400, { error: 'text requis' }) const agent = req.headers['x-authentik-email'] || 'agent' conv.notes = conv.notes || []; const note = { id: crypto.randomUUID(), agent, text, ts: new Date().toISOString() } conv.notes.push(note); conv.lastHumanDate = todayET(); saveToDisk() sse.broadcast('conversations', 'conv-note', { token, note }) // note INTERNE (jamais envoyée au client) → remplace le reply-all « je m'en occupe » return json(res, 200, { ok: true, note }) } if (sub === 'note' && method === 'PATCH') { // corriger une note (faute de frappe…) const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const b = await parseBody(req); const id = String(b.id || ''); const text = String(b.text || '').trim() if (!id || !text) return json(res, 400, { error: 'id + text requis' }) const note = (conv.notes || []).find(n => n.id === id); if (!note) return json(res, 404, { error: 'Note introuvable' }) note.text = text; note.edited = new Date().toISOString(); saveToDisk() sse.broadcast('conversations', 'conv-note-upd', { token, note }) return json(res, 200, { ok: true, note }) } if (sub === 'note' && method === 'DELETE') { // supprimer une note (erreur) const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const b = await parseBody(req); const id = String(b.id || ''); if (!id) return json(res, 400, { error: 'id requis' }) const before = (conv.notes || []).length conv.notes = (conv.notes || []).filter(n => n.id !== id) if (conv.notes.length === before) return json(res, 404, { error: 'Note introuvable' }) saveToDisk() sse.broadcast('conversations', 'conv-note-del', { token, id }) return json(res, 200, { ok: true, id }) } if (sub === 'nl' && method === 'POST') { // commande en langage naturel (copilote) const b = await parseBody(req) const r = await nlCommand(token, b.command || '', req.headers['x-authentik-email'] || '') return json(res, r.ok ? 200 : 400, r) } if (sub === 'pipeline' && method === 'POST') { // entrer/déplacer/retirer un lead dans le pipeline const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const b = await parseBody(req) conv.pipeline = conv.pipeline || {} if (b.stage != null) conv.pipeline.stage = String(b.stage) if (b.value != null) conv.pipeline.value = Number(b.value) || 0 conv.pipeline.updatedAt = new Date().toISOString() if (!conv.pipeline.stage) conv.pipeline = null // étape vide = retiré du pipeline saveToDisk() sse.broadcast('conversations', 'conv-pipeline', { token, pipeline: conv.pipeline }) return json(res, 200, { ok: true, pipeline: conv.pipeline }) } // Rattacher la conversation à une fiche client choisie (résout le cas multi-fiches). if (sub === 'link' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const b = await parseBody(req) if (b.unlink || b.customer === '') { // délier la fiche → réaffiche « Lier une fiche » conv.customer = ''; conv.customerName = ''; conv.candidates = null; saveToDisk() sse.broadcast('conversations', 'conv-update', { token: conv.token, customer: '', customerName: '' }) return json(res, 200, { ok: true, customer: '', customerName: '' }) } if (!b.customer) return json(res, 400, { error: 'customer requis' }) conv.customer = String(b.customer); conv.customerName = b.customerName || String(b.customer); conv.candidates = null // Compte lié → on suggère de PROMOUVOIR le fil en ticket (le bouton apparaît dans l'Inbox ; clic = import du fil + réponses [Ticket #N]). if (!conv.suggestedTicket && !(conv.linkedTickets && conv.linkedTickets.length)) { const lastCust = [...(conv.messages || [])].reverse().find(m => m.from === 'customer') conv.suggestedTicket = { title: conv.subject || (lastCust && String(lastCust.text || '').replace(/^✉️[^\n]*\n+/, '').slice(0, 90)) || 'Demande client', category: 'Support', priority: 'Medium', reason: 'Compte client lié — promouvoir le fil en ticket', ts: new Date().toISOString() } } saveToDisk() sse.broadcast('conversations', 'conv-update', { token: conv.token, customer: conv.customer, customerName: conv.customerName }) return json(res, 200, { ok: true, token: conv.token, customer: conv.customer, customerName: conv.customerName }) } // LIER (interdépendances omnichannel, style ClickUp) : relie CE fil à un autre fil / une fiche client / un ticket. // Stocké sur `conv.links` (persisté via PG comme le reste). Lien conv↔conv = bidirectionnel. `remove:true` retire. if (sub === 'relate' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const b = await parseBody(req) const type = String(b.targetType || '').trim() // 'conversation' | 'customer' | 'ticket' const id = String(b.targetId || '').trim() if (!type || !id) return json(res, 400, { error: 'targetType + targetId requis' }) const by = req.headers['x-authentik-email'] || null const rel = String(b.rel || 'related') const sameKey = (l) => l.type === type && String(l.id) === id conv.links = Array.isArray(conv.links) ? conv.links : [] if (b.remove) { conv.links = conv.links.filter(l => !sameKey(l)) if (type === 'conversation') { const o = getConversation(id); if (o && Array.isArray(o.links)) o.links = o.links.filter(l => !(l.type === 'conversation' && String(l.id) === token)) } } else { if (!conv.links.some(sameKey)) conv.links.push({ type, id, label: String(b.targetLabel || '').slice(0, 120), rel, by, at: new Date().toISOString() }) if (type === 'conversation') { // miroir bidirectionnel : l'autre fil pointe aussi vers celui-ci const o = getConversation(id) if (o) { o.links = Array.isArray(o.links) ? o.links : []; if (!o.links.some(l => l.type === 'conversation' && String(l.id) === token)) o.links.push({ type: 'conversation', id: token, label: (conv.customerName || conv.email || 'Conversation'), rel, by, at: new Date().toISOString() }) } } } saveToDisk() sse.broadcast('conv:' + conv.token, 'conv-update', { token: conv.token }) sse.broadcast('conversations', 'conv-update', { token: conv.token }) return json(res, 200, { ok: true, links: conv.links }) } // PRIORITÉ (style ClickUp) : urgent | high | normal | low | '' (aucune). Sur conv.priority (persiste via PG meta). if (sub === 'priority' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const b = await parseBody(req) const p = ['high', 'medium', 'low', 'urgent', 'normal', ''].includes(b.priority) ? b.priority : '' // 3 niveaux (high/medium/low) ; urgent/normal gardés pour rétro-compat conv.priority = p; saveToDisk() const payload = { token: conv.token, priority: p } sse.broadcast('conv:' + conv.token, 'conv-update', payload) sse.broadcast('conversations', 'conv-update', payload) return json(res, 200, { ok: true, priority: p }) } // Présence agent : signale que CET agent (Authentik) est en train d'écrire → visible par les AUTRES agents OPS. if (sub === 'typing' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const agentEmail = req.headers['x-authentik-email'] || null const sid = (url && url.searchParams.get('sid')) || null // identifiant d'onglet (filtre côté client) if (agentEmail) { const payload = { token: conv.token, agent: agentEmail, sid, customer: conv.customer, customerName: conv.customerName } sse.broadcast('conv:' + conv.token, 'conv-agent-typing', payload) sse.broadcast('conversations', 'conv-agent-typing', payload) } return json(res, 200, { ok: true }) } // « Vu par » : marque que CET agent a ouvert/lu la conversation → avatars chez les autres agents. if (sub === 'read' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const agentEmail = req.headers['x-authentik-email'] || null if (agentEmail) { const lastTs = conv.messages.length ? conv.messages[conv.messages.length - 1].ts : null conv.readBy = conv.readBy || {} conv.readBy[agentEmail] = { readAt: new Date().toISOString(), lastTs } saveToDisk() const payload = { token: conv.token, agent: agentEmail, readAt: conv.readBy[agentEmail].readAt, lastTs } sse.broadcast('conv:' + conv.token, 'conv-read', payload) sse.broadcast('conversations', 'conv-read', payload) } return json(res, 200, { ok: true, readBy: conv.readBy || {} }) } // Brouillon partagé (miroir temps réel) : un agent rédige, les autres voient le texte en direct. // Dernier qui écrit gagne (pas de co-édition simultanée). Vidé quand html/text sont vides (envoi/annulation). if (sub === 'draft' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const agentEmail = req.headers['x-authentik-email'] || null const sid = (url && url.searchParams.get('sid')) || null const body = await parseBody(req) const html = String(body.html || '') const text = String(body.text || '') const replyTo = body.replyTo ? String(body.replyTo) : null // id du message PRÉCIS auquel le brouillon répond → ré-ancrage dans le fil conv.draft = (html.trim() || text.trim()) ? { agent: agentEmail, sid, html, text, replyTo, ts: new Date().toISOString() } : null saveToDisk() const payload = { token: conv.token, agent: agentEmail, sid, draft: conv.draft } sse.broadcast('conv:' + conv.token, 'conv-draft', payload) sse.broadcast('conversations', 'conv-draft', payload) return json(res, 200, { ok: true }) } if (sub === 'messages' && method === 'POST') { const conv = getConversation(token) if (!conv) return json(res, 404, { error: 'Conversation not found or expired' }) const body = await parseBody(req) const text = (body.text || '').trim() const media = body.media || '' const html = body.html || '' // réponse WYSIWYG (canal email) const attachments = Array.isArray(body.attachments) ? body.attachments : [] if (!text && !media && !html && !attachments.length) return json(res, 400, { error: 'Missing text or media' }) const cc = String(body.cc || '').trim() // Cc éditable sur la réponse (« répondre à tous ») const toOverride = String(body.to || '').trim() // À éditable (défaut = client si vide) const agentEmail = req.headers['x-authentik-email'] const from = agentEmail ? 'agent' : 'customer' const isEmail = conv.channel === 'email' 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 || '', attachments, sendAs: body.sendAs || '', toRaw: (from === 'agent' && isEmail) ? (toOverride || conv.email || '') : '', ccRaw: cc }) if (from === 'agent') { msg.cc = cc; if (toOverride) msg.replyToOverride = toOverride; msg.notifiedVia = await notifyCustomer(conv, msg) } // email → envoi HTML dans le fil (To/Cc inclus) ; sinon push/SMS if (from === 'customer' && conv.customer && !isEmail) triggerAgent(conv) // pas d'auto-réponse IA sur courriel if (isEmail && conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, msg, conv.lastSubject).catch(() => {}) // chaîne : alimente le ticket lié (Issue ERPNext) if (from === 'agent' && conv.linkedTickets && conv.linkedTickets.some(t => t && t.legacy_ticket_id)) mirrorAgentReplyToLegacyTicket(conv, msg, agentEmail).catch(() => {}) // P2 : miroir réponse → fil osTicket legacy (F sync) if (conv.customer) { createCommunication({ communication_type: 'Communication', communication_medium: isEmail ? 'Email' : 'Chat', sent_or_received: from === 'agent' ? 'Sent' : 'Received', sender: from === 'agent' ? (agentEmail || (isEmail ? (cfg.MAIL_FROM || 'cc@targointernet.com') : 'sms@gigafibre.ca')) : (isEmail ? (conv.email || '') : conv.phone), sender_full_name: from === 'agent' ? 'Targo Ops' : conv.customerName, phone_no: conv.phone, content: (html || text || '') + (media ? '\n[image jointe]' : ''), subject: isEmail ? (conv.lastSubject || conv.subject) : `Chat: ${conv.subject}`, reference_doctype: 'Customer', reference_name: conv.customer, status: 'Linked', }).catch(() => {}) } return json(res, 201, { ok: true, message: msg }) } if (sub === 'push' && method === 'POST') { const body = await parseBody(req) if (!body.endpoint || !body.keys) return json(res, 400, { error: 'Invalid push subscription' }) const ok = registerPush(token, body) return json(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'Conversation not found' }) } if (sub === 'close' && method === 'POST') { const conv = getConversation(token) if (!conv) return json(res, 404, { error: 'Conversation not found' }) conv.status = 'closed' addMessage(conv, { from: 'system', text: 'Conversation terminée', via: 'web' }) saveToDisk() sse.broadcast('conversations', 'conv-closed', { token: conv.token }) return json(res, 200, { ok: true }) } if (sub === 'sse' && method === 'GET') { const conv = getConversation(token) if (!conv) return json(res, 404, { error: 'Conversation not found' }) res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }) res.write(': connected\n\n') sse.addClient(['conv-client:' + conv.token], res, 'customer:' + conv.phone) const keepalive = setInterval(() => { try { res.write(': ping\n\n') } catch { clearInterval(keepalive) } }, 25000) res.on('close', () => clearInterval(keepalive)) return } } return json(res, 404, { error: 'Not found' }) } module.exports = { handle, createTicketFromConversation, ingestEmail, channelIngest, sendNewEmail, sendSms, nlCommand, detectSplitTopics, splitConversation, mergeConversations, analyzePaymentProof, loadCanned, saveCanned, listConversations, createConversation, createDraftConversation, getOrCreateConvForLegacyTicket, getOrCreateConvForJob, findConvByJob, getConversation, addMessage, findConversationByPhone, autoLinkIncomingSms, triggerAgent, notifyCustomer, deleteConversation, deleteDiscussion, archiveDiscussion, }