'use strict' const cfg = require('./config') const { log } = require('./helpers') const erp = require('./erp') const { broadcastAll } = require('./sse') // Lazy require to avoid circular dependency with twilio.js function sendSmsInternal (phone, message) { return require('./twilio').sendSmsInternal(phone, message) } // ── Date helpers ── const DAY_NAMES = { lun: 1, lundi: 1, mar: 2, mardi: 2, mer: 3, mercredi: 3, jeu: 4, jeudi: 4, ven: 5, vendredi: 5, sam: 6, samedi: 6, dim: 0, dimanche: 0 } function localDateStr (d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') } function addDays (d, n) { const r = new Date(d); r.setDate(r.getDate() + n); return r } function nextWeekday (dayNum) { const now = new Date() let diff = dayNum - now.getDay() if (diff <= 0) diff += 7 return addDays(now, diff) } // ── ERPNext helpers ── async function lookupTechByPhone (phone) { const digits = phone.replace(/\D/g, '').slice(-10) // Build a LIKE pattern that matches digits regardless of separators (dashes, spaces, dots) // e.g. 5149490739 → %514%949%0739% const pattern = '%' + digits.replace(/(\d{3})(\d{3})(\d{4})/, '$1%$2%$3') + '%' const rows = await erp.list('Dispatch Technician', { fields: ['name', 'full_name', 'phone', 'status'], filters: [['phone', 'like', pattern]], limit: 1, }) return rows[0] || null } async function setTechAbsence (techName, { from, until, startTime, endTime, reason }) { const body = { status: 'En pause', absence_from: from, absence_until: until || from, absence_reason: reason || 'personal', absence_start_time: startTime || '', absence_end_time: endTime || '', } const res = await erp.update('Dispatch Technician', techName, body) if (res.ok) { log(`Tech absence set: ${techName} from=${from} until=${until || from}`) broadcastAll('tech-absence', { techName, action: 'set', from, until: until || from, startTime, endTime, reason }) return true } log(`Tech absence set FAILED: ${techName}`, res.error) return false } async function clearTechAbsence (techName) { const body = { status: 'Disponible', absence_from: '', absence_until: '', absence_reason: '', absence_start_time: '', absence_end_time: '', } const res = await erp.update('Dispatch Technician', techName, body) if (res.ok) { log(`Tech absence cleared: ${techName}`) broadcastAll('tech-absence', { techName, action: 'clear' }) return true } log(`Tech absence clear FAILED: ${techName}`, res.error) return false } // ── LLM parsing (Gemini Flash via OpenAI-compatible API) ── function buildSystemPrompt () { return `Tu es un assistant qui analyse les SMS reçus d'une personne qui est TECHNICIEN TERRAIN et possiblement aussi CLIENT de l'entreprise (FAI/télécoms). Aujourd'hui: ${localDateStr(new Date())} CONTEXTE IMPORTANT: Cette personne a deux rôles possibles: - Employé/technicien: messages liés au travail, présence, absences, disponibilité - Client: messages liés à son service internet, facturation, problèmes techniques personnels Tu dois déterminer si le message concerne le RÔLE EMPLOYÉ ou le RÔLE CLIENT. Retourne UNIQUEMENT du JSON valide, rien d'autre. Si le message concerne une ABSENCE EMPLOYÉ: {"action":"absent","from":"YYYY-MM-DD","until":"YYYY-MM-DD","startTime":"HH:MM or null","endTime":"HH:MM or null","reason":"personal|medical|family|training|other","fullDay":true/false} Si c'est un RETOUR au travail: {"action":"retour"} Si le message concerne le RÔLE CLIENT (problème internet, facture, service) ou n'est PAS lié au travail: {"action":"none"} EXEMPLES employé → absent/retour: - "absent demain" → absent - "je serai pas là vendredi" → absent - "malade" → absent, medical - "rdv dentiste 14h-16h" → absent partiel, medical - "formation jeudi" → absent, training - "mon fils est malade je reste à la maison" → absent, family - "retour", "dispo", "de retour" → retour EXEMPLES client → none: - "mon internet est lent" → none (problème de service client) - "j'ai pas reçu ma facture" → none (facturation client) - "mon wifi ne marche plus" → none (support technique client) - "je veux changer mon forfait" → none (demande client) - "salut, as-tu le numéro du client?" → none (conversation générale) Règles dates: - "absent" sans date = aujourd'hui - "demain" = jour suivant - "lundi/mardi/..." = prochain jour nommé - "3j" ou "3 jours" = aujourd'hui + n-1 jours - startTime/endTime = null si journée complète - Heures au format 24h (HH:MM)` } function llmRequest (messages) { return new Promise((resolve, reject) => { const baseUrl = cfg.AI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta/openai/' const url = new URL(baseUrl + 'chat/completions') const isHttps = url.protocol === 'https:' const client = isHttps ? require('https') : require('http') const payload = JSON.stringify({ model: cfg.AI_MODEL || 'gemini-2.5-flash', messages, temperature: 0.1, max_tokens: 1024, }) const headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } if (cfg.AI_API_KEY) headers['Authorization'] = 'Bearer ' + cfg.AI_API_KEY const req = client.request({ hostname: url.hostname, port: url.port || (isHttps ? 443 : 80), path: url.pathname + url.search, method: 'POST', headers, timeout: 15000, }, (res) => { let body = '' res.on('data', c => body += c) res.on('end', () => { try { const data = JSON.parse(body) const content = data.choices?.[0]?.message?.content || '' resolve(content) } catch { reject(new Error('LLM parse error: ' + body.substring(0, 200))) } }) }) req.on('error', reject) req.on('timeout', () => { req.destroy(); reject(new Error('LLM timeout')) }) req.write(payload) req.end() }) } async function parseWithLlm (text) { if (!cfg.AI_API_KEY && !cfg.AI_BASE_URL) return null try { const raw = await llmRequest([ { role: 'system', content: buildSystemPrompt() }, { role: 'user', content: text }, ]) // Strip markdown fences, tags, extract JSON object let cleaned = raw.replace(/[\s\S]*?<\/think>/g, '').replace(/```json\s*/g, '').replace(/```/g, '').trim() // Extract first JSON object if surrounded by text const jsonMatch = cleaned.match(/\{[\s\S]*\}/) if (jsonMatch) cleaned = jsonMatch[0] const parsed = JSON.parse(cleaned) if (parsed.action && ['absent', 'retour', 'none'].includes(parsed.action)) { log(`LLM parsed SMS: "${text}" → ${JSON.stringify(parsed)}`) return parsed } log(`LLM unexpected result: ${JSON.stringify(parsed)}`) } catch (e) { log(`LLM error: ${e.message}`) } return null } // ── Regex fallback ── function parseWithRegex (text) { const normalized = text.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') // Return patterns if (/^(retour|dispo|disponible|present|de retour|je reviens)$/i.test(normalized)) { return { action: 'retour' } } // Absence patterns const absMatch = normalized.match(/^(absent|malade|sick|formation)\s*(.*)$/i) if (!absMatch) return null const keyword = absMatch[1] const rest = absMatch[2].trim() const today = new Date() let from = localDateStr(today), until = null, startTime = null, endTime = null let reason = 'personal' if (keyword === 'malade' || keyword === 'sick') reason = 'medical' if (keyword === 'formation') reason = 'training' // Time range const timeMatch = rest.match(/(\d{1,2})[h:]?(\d{0,2})\s*[-àa]\s*(\d{1,2})[h:]?(\d{0,2})/) if (timeMatch) { startTime = timeMatch[1].padStart(2, '0') + ':' + (timeMatch[2] || '00').padStart(2, '0') endTime = timeMatch[3].padStart(2, '0') + ':' + (timeMatch[4] || '00').padStart(2, '0') } const dateRest = rest.replace(/(\d{1,2})[h:]?\d{0,2}\s*[-àa]\s*\d{1,2}[h:]?\d{0,2}/, '').trim() if (dateRest === 'demain' || dateRest === 'dem') { from = localDateStr(addDays(today, 1)) } else if (dateRest.match(/^(\d+)\s*j(ours?)?$/)) { const n = parseInt(dateRest.match(/^(\d+)/)[1], 10) until = localDateStr(addDays(today, n - 1)) } else { for (const [name, dayNum] of Object.entries(DAY_NAMES)) { if (dateRest === name) { from = localDateStr(nextWeekday(dayNum)); break } } } return { action: 'absent', from, until: until || from, startTime, endTime, reason, fullDay: !startTime } } // ── Main handler ── async function handleAbsenceSms (from, text) { // First: is this from a known tech? const tech = await lookupTechByPhone(from) if (!tech) return false // Try LLM first (Gemini Flash), fallback to regex let parsed = await parseWithLlm(text) if (!parsed) { parsed = parseWithRegex(text) if (parsed) log(`Regex fallback parsed: "${text}" → ${JSON.stringify(parsed)}`) } if (!parsed || parsed.action === 'none') return false if (parsed.action === 'retour') { const ok = await clearTechAbsence(tech.name) if (ok) await sendSmsInternal(from, `✅ ${tech.full_name}, ton statut est remis à Disponible. Bonne journée!`) return true } if (parsed.action === 'absent') { const fromDate = parsed.from || localDateStr(new Date()) const untilDate = parsed.until || fromDate const ok = await setTechAbsence(tech.name, { from: fromDate, until: untilDate, startTime: parsed.startTime || null, endTime: parsed.endTime || null, reason: parsed.reason || 'personal', }) if (ok) { let msg = `✅ ${tech.full_name}, absence enregistrée pour le ${fromDate}` if (untilDate !== fromDate) msg = `✅ ${tech.full_name}, absence enregistrée du ${fromDate} au ${untilDate}` if (parsed.startTime && parsed.endTime) msg += ` de ${parsed.startTime} à ${parsed.endTime}` msg += `. Écris "retour" quand tu es disponible.` await sendSmsInternal(from, msg) } return true } return false } module.exports = { handleAbsenceSms, parseWithLlm, parseWithRegex, lookupTechByPhone }