'use strict' const crypto = require('crypto') const fs = require('fs') const path = require('path') const cfg = require('./config') const { log, json, parseBody, lookupCustomerByPhone, createCommunication, erpFetch } = require('./helpers') const sse = require('./sse') const DATA_DIR = path.join(__dirname, '..', 'data') const CONV_FILE = path.join(DATA_DIR, 'conversations.json') const conversations = new Map() const pushSubscriptions = new Map() const agentTimers = new Map() function loadFromDisk () { try { if (!fs.existsSync(CONV_FILE)) return for (const conv of JSON.parse(fs.readFileSync(CONV_FILE, 'utf8'))) conversations.set(conv.token, conv) log(`Loaded ${conversations.size} conversations from disk`) } catch (e) { log('Failed to load conversations:', e.message) } } let saveTimer = null function saveToDisk () { if (saveTimer) return saveTimer = setTimeout(() => { saveTimer = null try { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) fs.writeFileSync(CONV_FILE + '.tmp', JSON.stringify([...conversations.values()], null, 0)) fs.renameSync(CONV_FILE + '.tmp', CONV_FILE) } catch (e) { log('Failed to save conversations:', e.message) } }, 500) } loadFromDisk() function generateToken () { return crypto.randomBytes(4).toString('base64url') } function createConversation ({ phone, customer, customerName, agentEmail, subject }) { const token = generateToken() const now = new Date().toISOString() const conv = { token, phone, customer: customer || null, customerName: customerName || 'Client', agentEmail: agentEmail || null, subject: subject || 'Conversation', status: 'active', messages: [], createdAt: now, lastActivity: now, expiresAt: new Date(Date.now() + 48 * 3600000).toISOString(), smsCount: 0, pushSub: null, } conversations.set(token, conv) saveToDisk() log(`Conversation created: ${token} for ${phone} (${customerName})`) return conv } function getConversation (token) { const conv = conversations.get(token) if (!conv) return null if (new Date(conv.expiresAt) < new Date()) { conv.status = 'expired'; return null } return conv } function addMessage (conv, { from, text, type = 'text', via = 'web' }) { const msg = { id: crypto.randomUUID(), from, text, type, via, ts: new Date().toISOString() } conv.messages.push(msg) conv.lastActivity = msg.ts saveToDisk() sse.broadcast('conv:' + conv.token, 'conv-message', { token: conv.token, message: msg }) sse.broadcast('conv-client:' + conv.token, 'conv-message', { message: msg }) sse.broadcast('conversations', 'conv-message', { token: conv.token, customer: conv.customer, customerName: conv.customerName, message: msg }) return msg } function registerPush (token, subscription) { const conv = getConversation(token) if (!conv) return false conv.pushSub = subscription pushSubscriptions.set(token, subscription) saveToDisk() log(`Push subscription registered for conv ${token}`) return true } async function sendPushNotification (conv, message) { if (!conv.pushSub || !cfg.VAPID_PUBLIC_KEY || !cfg.VAPID_PRIVATE_KEY) return false try { const webpush = require('web-push') webpush.setVapidDetails('mailto:support@gigafibre.ca', cfg.VAPID_PUBLIC_KEY, cfg.VAPID_PRIVATE_KEY) await webpush.sendNotification(conv.pushSub, JSON.stringify({ title: 'Gigafibre', body: message.text?.substring(0, 100) || 'Nouveau message', url: `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`, tag: 'conv-' + conv.token, })) log(`Push sent for conv ${conv.token}`) return true } catch (e) { log(`Push failed for conv ${conv.token}:`, e.message) if (e.statusCode === 410 || e.statusCode === 404) { conv.pushSub = null; pushSubscriptions.delete(conv.token); saveToDisk() } return false } } async function sendSmsFallback (conv, text) { if (conv.smsCount >= 2) { log(`SMS limit reached for conv ${conv.token}, skipping`); return false } const { sendSmsInternal } = require('./twilio') const success = await sendSmsInternal(conv.phone, text, conv.customer) if (success) { conv.smsCount++; saveToDisk() } return success } async function notifyCustomer (conv, message) { const pushed = await sendPushNotification(conv, message) if (pushed) return 'push' const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}` const smsText = `${message.text?.substring(0, 100) || 'Nouveau message'}\n\nRépondre: ${link}` return (await sendSmsFallback(conv, smsText)) ? '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 customer = await lookupCustomerByPhone(phone) const { sendSmsInternal } = require('./twilio') if (!customer) { const conv = createConversation({ phone, customer: null, customerName: phone, agentEmail: null, subject: 'SMS entrant (inconnu)' }) conv.smsCount = 0 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 } 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 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 }) } function listConversations ({ includeAll = false } = {}) { const out = [] for (const [, conv] of conversations) { const expired = new Date(conv.expiresAt) < new Date() if (!includeAll && (conv.status !== 'active' || expired)) continue out.push({ token: conv.token, phone: conv.phone, customer: conv.customer, customerName: conv.customerName, agentEmail: conv.agentEmail, subject: conv.subject, status: expired ? 'expired' : conv.status, messageCount: conv.messages.length, lastActivity: conv.lastActivity, createdAt: conv.createdAt, lastMessage: conv.messages.length ? conv.messages[conv.messages.length - 1] : null, }) } out.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)) const discussions = {} for (const c of out) { const phoneKey = c.phone.replace(/\D/g, '').slice(-10) const key = c.status === 'active' ? `${phoneKey}:active` : `${phoneKey}:${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, customer: c.customer, customerName: c.customerName, date: day, conversations: [], messages: [], lastActivity: c.lastActivity, status: 'closed', } } discussions[key].conversations.push(c) if (c.status === 'active') discussions[key].status = 'active' if (c.lastActivity > discussions[key].lastActivity) { discussions[key].lastActivity = c.lastActivity if (discussions[key].status === 'active') discussions[key].date = c.lastActivity.slice(0, 10) } } for (const d of Object.values(discussions)) { const allMsgs = [] for (const c of d.conversations) { const conv = conversations.get(c.token) if (!conv) continue for (const m of conv.messages) allMsgs.push({ ...m, convToken: c.token }) } allMsgs.sort((a, b) => a.ts.localeCompare(b.ts)) d.messages = allMsgs d.messageCount = allMsgs.length d.lastMessage = allMsgs.length ? allMsgs[allMsgs.length - 1] : null } return { conversations: out, discussions: Object.values(discussions).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)) } } function deleteConversation (token) { const existed = conversations.delete(token) if (existed) saveToDisk() return existed } function deleteDiscussionByTokens (tokens) { let count = 0 for (const t of tokens) if (conversations.delete(t)) count++ if (count) saveToDisk() return count } function deleteDiscussion (phone, date) { const phoneDigits = phone.replace(/\D/g, '').slice(-10) const tokensToDelete = [] for (const [token, conv] of conversations) { if (conv.phone.replace(/\D/g, '').slice(-10) !== phoneDigits) continue if (date === 'active') { if (conv.status === 'active') tokensToDelete.push(token) } else if (conv.createdAt.slice(0, 10) === date && conv.status !== 'active') tokensToDelete.push(token) } for (const t of tokensToDelete) conversations.delete(t) if (tokensToDelete.length) saveToDisk() return tokensToDelete.length } async function archiveDiscussion (tokens) { const allMsgs = [] let customer = null, customerName = null, phone = null, subject = null for (const token of tokens) { const conv = conversations.get(token) if (!conv) continue if (!customer && conv.customer) customer = conv.customer if (!customerName && conv.customerName) customerName = conv.customerName if (!phone) phone = conv.phone if (!subject && conv.subject) subject = conv.subject for (const m of conv.messages) allMsgs.push(m) } if (!allMsgs.length) return null // If no customer linked, try to lookup by phone number if (!customer && phone) { try { const found = await lookupCustomerByPhone(phone) if (found) { customer = found.name if (!customerName || customerName === phone) customerName = found.customer_name || found.name } } catch {} } allMsgs.sort((a, b) => a.ts.localeCompare(b.ts)) const lines = allMsgs.map(m => { if (m.from === 'system') return `--- ${m.text} ---` const sender = m.from === 'customer' ? (customerName || phone || 'Client') : m.via === 'ai' ? 'AI Assistant' : 'Agent' const time = new Date(m.ts).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) const via = m.via === 'sms' ? ' [SMS]' : m.via === 'ai' ? ' [AI]' : '' return `**${sender}**${via} (${time}):\n${m.text}` }) const issueSubject = `[Archivé] ${subject || 'Discussion'} — ${customerName || phone}` const issueData = { subject: issueSubject, status: 'Closed', priority: 'Low', issue_type: 'SMS/Chat', raised_by: phone || '', description: lines.join('\n\n'), } // Only set customer if we have a valid one — avoid sending null/undefined to ERPNext if (customer) issueData.customer = customer try { const result = await erpFetch('/api/resource/Issue', { method: 'POST', body: JSON.stringify(issueData) }) const issueName = result?.data?.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 } } const TK = /^\/conversations\/([A-Za-z0-9_-]{5,10})$/ const TK_SUB = /^\/conversations\/([A-Za-z0-9_-]{5,10})\// async function handle (req, res, method, p, url) { if (p === '/conversations' && method === 'POST') { const body = await parseBody(req) const { phone, customer, customerName, subject } = 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 }) const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}` const smsText = subject ? `Gigafibre — ${subject}\n\nContinuez la conversation ici:\n${link}` : `Gigafibre — Vous avez un nouveau message.\n\nRépondre ici:\n${link}` try { const { sendSmsInternal } = require('./twilio') await sendSmsInternal(phone, smsText, customer) conv.smsCount++; saveToDisk() } catch (e) { log('Failed to send magic 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: smsText, subject: 'Conversation link sent', reference_doctype: 'Customer', reference_name: customer, status: 'Linked', }).catch(() => {}) } return json(res, 201, { ok: true, token: conv.token, link }) } if (p === '/conversations' && method === 'GET') return json(res, 200, listConversations({ includeAll: url.searchParams.get('all') === '1' })) 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 }) } 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, vapidPublicKey: cfg.VAPID_PUBLIC_KEY || null, }) } if ((m = p.match(TK)) && method === 'DELETE') { const existed = deleteConversation(m[1]) 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] if (sub === 'messages' && method === 'POST') { const conv = getConversation(token) if (!conv) return json(res, 404, { error: 'Conversation not found or expired' }) const { text } = await parseBody(req) if (!text || !text.trim()) return json(res, 400, { error: 'Missing text' }) const agentEmail = req.headers['x-authentik-email'] const from = agentEmail ? 'agent' : 'customer' const msg = addMessage(conv, { from, text: text.trim(), via: 'web' }) if (from === 'agent') msg.notifiedVia = await notifyCustomer(conv, msg) if (from === 'customer' && conv.customer) triggerAgent(conv) if (conv.customer) { createCommunication({ communication_type: 'Communication', communication_medium: 'Chat', sent_or_received: from === 'agent' ? 'Sent' : 'Received', sender: from === 'agent' ? (agentEmail || 'sms@gigafibre.ca') : conv.phone, sender_full_name: from === 'agent' ? 'Targo Ops' : conv.customerName, phone_no: conv.phone, content: text.trim(), 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, createConversation, getConversation, addMessage, findConversationByPhone, autoLinkIncomingSms, triggerAgent, notifyCustomer, deleteConversation, deleteDiscussion, archiveDiscussion, }