import { prisma } from './prisma'; import { scheduleMessage, enrollUser, whatsappQueue, scheduleInteractiveButtons, scheduleInteractiveList } from './queue'; export class WhatsAppService { private static normalizeCommand(text: string): string { return text .trim() .toLowerCase() .replace(/[.,!?;:]+$/, "") // Remove trailing punctuation .toUpperCase(); } private static detectIntent(text: string): 'YES' | 'NO' | 'UNKNOWN' { const normalized = text.trim().toLowerCase().replace(/[.,!?;:]+$/, ""); const yesWords = ['oui', 'ouais', 'wi', 'waaw', 'yes', 'yep', 'ok', 'd’accord', 'daccord', 'da’accord']; const noWords = ['non', 'déet', 'deet', 'no', 'nah', 'nein']; if (yesWords.some(w => normalized.includes(w))) return 'YES'; if (noWords.some(w => normalized.includes(w))) return 'NO'; return 'UNKNOWN'; } private static levenshteinDistance(a: string, b: string): number { const matrix: number[][] = []; for (let i = 0; i <= b.length; i++) matrix[i] = [i]; for (let j = 0; j <= a.length; j++) matrix[0][j] = j; for (let i = 1; i <= b.length; i++) { for (let j = 1; j <= a.length; j++) { if (b.charAt(i - 1) === a.charAt(j - 1)) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, // substitution matrix[i][j - 1] + 1, // insertion matrix[i - 1][j] + 1 // deletion ); } } } return matrix[b.length][a.length]; } private static isFuzzyMatch(text: string, target: string, threshold = 0.8): boolean { const normalized = text.trim().toUpperCase(); const tar = target.toUpperCase(); if (normalized === tar) return true; if (normalized.includes(tar) || tar.includes(normalized)) return true; const distance = this.levenshteinDistance(normalized, tar); const maxLength = Math.max(normalized.length, tar.length); const similarity = 1 - distance / maxLength; return similarity >= threshold; } static async handleIncomingMessage(phone: string, text: string, audioUrl?: string) { const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`; const normalizedText = this.normalizeCommand(text); console.log(`${traceId} Received: ${normalizedText} (Audio: ${audioUrl || 'N/A'})`); // 1. Find or Create User let user = await prisma.user.findUnique({ where: { phone } }); if (!user) { const isInscription = this.isFuzzyMatch(normalizedText, 'INSCRIPTION') || normalizedText.includes('INSCRI') || normalizedText.includes('INSCRI'); if (isInscription) { console.log(`${traceId} New user registration triggered for ${phone}`); user = await prisma.user.create({ data: { phone } }); await scheduleInteractiveButtons(user.id, "Dalal jàmm! Xamle ngay tàmbali. ⏳ 30s.\n(FR) Ton cours se prépare (30s).", [ { id: 'LANG_FR', title: 'Français 🇫🇷' }, { id: 'LANG_WO', title: 'Wolof 🇸🇳' } ] ); return; } else { console.log(`${traceId} Unregistered user ${phone} sent: "${normalizedText}". Sending instructions.`); // Anti-silence: Nudge them to register const { whatsappQueue } = await import('./queue'); await whatsappQueue.add('send-message-direct', { phone, text: "🎓 Bienvenue chez XAMLÉ !\nPour commencer ta formation gratuite, envoie le mot : *INSCRIPTION*\n\n(WO) Dalal jàmm ! Ngir tàmbali sa njàng mburu, bindal : *INSCRIPTION*" }); return; } } // 1.2 Log the incoming message in the DB try { await prisma.message.create({ data: { content: text, mediaUrl: audioUrl, direction: 'INBOUND', userId: user.id } }); } catch (err: unknown) { console.error('[WhatsAppService] Failed to log incoming message:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))); } // 1.5. Testing / Cheat Codes (Only for registered users) if (this.isFuzzyMatch(normalizedText, 'INSCRIPTION')) { await prisma.enrollment.deleteMany({ where: { userId: user.id } }); await prisma.userProgress.deleteMany({ where: { userId: user.id } }); await prisma.response.deleteMany({ where: { userId: user.id } }); // Also explicitly clear business AI profile to prevent context leak on restart await (prisma as any).businessProfile.deleteMany({ where: { userId: user.id } }); user = await prisma.user.update({ where: { id: user.id }, data: { city: null, activity: null } }); await scheduleInteractiveButtons(user.id, "Réinitialisation réussie – choisissez votre langue / Tànnal sa làkk :", [ { id: 'LANG_FR', title: 'Français 🇫🇷' }, { id: 'LANG_WO', title: 'Wolof 🇸🇳' } ] ); return; } if (normalizedText === 'TEST_IMAGE') { await whatsappQueue.add('send-image', { to: user.phone, imageUrl: 'https://r2.xamle.sn/branding/branding_xamle.png', caption: 'Branding XAMLÉ - Industrialisation 2026' }); return; } if (normalizedText.startsWith('TEST_VIDEO')) { const parts = normalizedText.split(' '); if (parts.length < 3) { await scheduleMessage(user.id, "Usage: TEST_VIDEO "); return; } const trackId = parts[1]; const dayNumber = parseFloat(parts[2]); await scheduleMessage(user.id, `🧪 Test Video pour ${trackId} J${dayNumber}...`); await whatsappQueue.add('send-content', { userId: user.id, trackId, dayNumber }); return; } // 🚨 Guardrail "Contenu Vide" / Gibberish (UX Engineer Requirement) const wordCount = (text || '').trim().split(/\s+/).length; const systemCommands = ['1', '2', 'SUITE', 'APPROFONDIR', 'INSCRIPTION', 'SEED']; const isSystemCommand = systemCommands.some(cmd => this.isFuzzyMatch(normalizedText, cmd)) || normalizedText.includes('INSCRI'); if (wordCount < 3 && !isSystemCommand) { await scheduleMessage(user.id, user.language === 'WOLOF' ? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir (mbebetu 3 baat). Waxtaanal ak man !" : "Je n'ai pas bien compris ton activité. Peux-tu me réexpliquer en quelques mots ce que tu fais ? (Minimum 3 mots)"); return; } if (this.isFuzzyMatch(normalizedText, 'SEED')) { // Reply immediately so the webhook doesn't time out console.log(`[SEED] Triggered by user ${user.id}`); try { // @ts-ignore - dynamic import of sub-module const { seedDatabase } = await import('@repo/database/seed'); const result = await seedDatabase(prisma); console.log('[SEED] Result:', result.message); // 🚨 COGNITIVE CACHE CLEAR: Delete old BusinessProfile contexts to prevent agricultural hallucinations try { await (prisma as any).businessProfile.deleteMany({ where: { userId: user.id } }); await prisma.user.update({ where: { id: user.id }, data: { activity: null } }); console.log(`[SEED] Cleared cognitive cache for User ${user.id}`); } catch (cacheErr: unknown) { console.error('[SEED] Failed to clear cognitive cache:', (cacheErr as Error).message); } await scheduleMessage(user.id, result.seeded ? "✅ Seeding terminé ! Le Cache Cognitif a été réinitialisé.\nEnvoie INSCRIPTION pour commencer." : "ℹ️ Les données existent déjà. Cache Cognitif purgé. Envoie INSCRIPTION." ); } catch (err: unknown) { console.error('[SEED] Error:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))); await scheduleMessage(user.id, `❌ Erreur seed : ${(err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))?.substring(0, 200)}`); } return; } // ─── Interactive LIST action IDs ────────────────────────────────────── // Format: DAY{N}_EXERCISE | DAY{N}_REPLAY | DAY{N}_CONTINUE | DAY{N}_PROMPT const dayActionMatch = normalizedText.match(/^DAY(\d+)_(EXERCISE|REPLAY|CONTINUE|PROMPT)$/); if (dayActionMatch) { const action = dayActionMatch[2]; if (action === 'REPLAY') { const enrollment = await prisma.enrollment.findFirst({ where: { userId: user.id, status: 'ACTIVE' } }); if (enrollment) { await whatsappQueue.add('send-content', { userId: user.id, trackId: enrollment.trackId, dayNumber: enrollment.currentDay }); await scheduleMessage(user.id, user.language === 'WOLOF' ? "🔁 Dinanu la yëgël waxtu bi ci kanam..." : "🔁 Je te renvoie la leçon dans quelques secondes..." ); } return; } else if (action === 'EXERCISE') { await scheduleMessage(user.id, user.language === 'WOLOF' ? "🎙️ Yónnee sa tontu (audio walla bind) :" : "🎙️ Envoie ta réponse (audio ou texte) :" ); return; } else if (action === 'PROMPT') { const enrollment = await prisma.enrollment.findFirst({ where: { userId: user.id, status: 'ACTIVE' } }); if (enrollment) { const trackDay = await prisma.trackDay.findFirst({ where: { trackId: enrollment.trackId, dayNumber: enrollment.currentDay } }); if (trackDay?.exercisePrompt) { await scheduleMessage(user.id, trackDay.exercisePrompt); } else { await scheduleMessage(user.id, user.language === 'WOLOF' ? "Amul lëjj" : "Pas d'exercice pour ce jour"); } } return; } else if (action === 'CONTINUE') { // Determine if there is a pending exercise before advancing const pendingProgress = await prisma.userProgress.findFirst({ where: { userId: user.id, exerciseStatus: { in: ['PENDING', 'PENDING_REMEDIATION'] } } }); if (pendingProgress) { await scheduleMessage(user.id, user.language === 'WOLOF' ? "Danga wara tontu lëjj bi balaa nga dem ci kanam. Tànnal 'Yónni tontu'." : "Tu dois d'abord répondre à l'exercice avant de continuer. Choisis 'Faire l'exercice' ou 'Répondre'." ); } else { // Safe to advance (either completed or dropped or already handled) await scheduleMessage(user.id, user.language === 'WOLOF' ? "Waaw, ñuy dem ci kanam !" : "C'est noté, on avance !" ); // To do: if advance needs to trigger scheduleTrackDay directly, it could be done here instead of tracking. // However, normally `SUITE` moves the day forward. } return; } } // 1.7. Language Selection (Interactive Buttons) if (normalizedText === 'LANG_FR' || normalizedText === 'LANG_WO') { const newLang = normalizedText === 'LANG_FR' ? 'FR' : 'WOLOF'; user = await prisma.user.update({ where: { id: user.id }, data: { language: newLang } }); const promptText = newLang === 'FR' ? "Parfait, nous allons continuer en Français ! 🇫🇷\nDans quel domaine d'activité te trouves-tu ?" : "Baax na, dinanu wéy ci Wolof ! 🇸🇳\nCi ban mbir ngay yëngu ?"; await scheduleInteractiveList( user.id, newLang === 'FR' ? "Ton secteur" : "Sa Mbir", promptText, newLang === 'FR' ? "Secteurs" : "Tànn", [{ title: newLang === 'FR' ? 'Liste' : 'Mbir', rows: [ { id: 'SEC_COMMERCE', title: newLang === 'FR' ? 'Commerce / Vente' : 'Njaay' }, { id: 'SEC_AGRI', title: newLang === 'FR' ? 'Agri / Élevage' : 'Mbay / Samm' }, { id: 'SEC_FOOD', title: newLang === 'FR' ? 'Alimentation / Rest.' : 'Lekk / Restauration' }, { id: 'SEC_COUTURE', title: newLang === 'FR' ? 'Couture / Mode' : 'Couture' }, { id: 'SEC_BEAUTE', title: newLang === 'FR' ? 'Beauté / Bien-être' : 'Rafet' }, { id: 'SEC_TRANSPORT', title: newLang === 'FR' ? 'Transport / Livr.' : 'Transport / Yëgël' }, { id: 'SEC_TECH', title: newLang === 'FR' ? 'Tech / Digital' : 'Tech / Digital' }, { id: 'SEC_AUTRE', title: newLang === 'FR' ? 'Autre secteur' : 'Beneen mbir' } ] }] ); return; } // 2. Check Pending Exercise (User Progress) // 2. Resolve sector LIST reply IDs → human-readable label const SECTOR_LABELS: Record = { SEC_COMMERCE: { fr: 'Commerce / Vente', wo: 'Njaay' }, SEC_AGRI: { fr: 'Agriculture / Élevage', wo: 'Mbay' }, SEC_FOOD: { fr: 'Alimentation / Restauration', wo: 'Lekk / Restauration' }, SEC_TECH: { fr: 'Tech / Digital', wo: 'Tech / Digital' }, SEC_BEAUTE: { fr: 'Beauté / Bien-être', wo: 'Rafet' }, SEC_COUTURE: { fr: 'Couture / Mode', wo: 'Couture' }, SEC_TRANSPORT: { fr: 'Transport / Livraison', wo: 'Transport / Yëgël' }, }; if (normalizedText === 'SEC_AUTRE') { await scheduleMessage(user.id, user.language === 'WOLOF' ? 'Waaw ! Wax ma ban mbir ngay def ci ab kàddu gatt :' : 'Parfait ! Décris ton activité en quelques mots :' ); return; } const sectorLabel = SECTOR_LABELS[normalizedText]; // 🚨 Brique 1 (Immuabilité) : Vérifier si l'utilisateur est déjà inscrit. const existingEnrollment = await prisma.enrollment.findFirst({ where: { userId: user.id, status: 'ACTIVE' } }); if (existingEnrollment && (sectorLabel || normalizedText.startsWith('SEC_'))) { console.log(`[IMMUTABILITY] User ${user.id} tried to change sector but is already enrolled.`); return; // Ignore and do not allow re-routing here } if (!existingEnrollment && (sectorLabel || (!user.activity && text.length > 2))) { const activity = sectorLabel ? (user.language === 'WOLOF' ? sectorLabel.wo : sectorLabel.fr) : text.trim(); user = await prisma.user.update({ where: { id: user.id }, data: { activity } }); const welcomeMsg = user.language === 'FR' ? `Parfait ! Secteur noté : *${activity}*.\nJe t'inscris à ta formation personnalisée !` : `Baax na ! Bind nanu la ci: *${activity}*.\nLéegi dinanu la dugal ci njàng mi !`; await scheduleMessage(user.id, welcomeMsg); const trackId = user.language === 'FR' ? "T1-FR" : "T1-WO"; const defaultTrack = await prisma.track.findUnique({ where: { id: trackId } }); if (defaultTrack) await enrollUser(user.id, defaultTrack.id); return; } // 3. Check Active Enrollment (Commands Priority) const activeEnrollment = await prisma.enrollment.findFirst({ where: { userId: user.id, status: 'ACTIVE' }, include: { track: true } }); if (activeEnrollment) { const intent = this.detectIntent(text); const isSuite = this.isFuzzyMatch(normalizedText, 'SUITE') || normalizedText === '2'; const isApprofondir = this.isFuzzyMatch(normalizedText, 'APPROFONDIR') || normalizedText === '1'; // Handle SUITE Priority if (isSuite) { const userProgress = await prisma.userProgress.findUnique({ where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } } }); // Strict check: you can only advance if the exercise is COMPLETED or if there's no mandatory exercise. // We err on the side of blocking if it's PENDING or null (unexpected state). if (userProgress?.exerciseStatus !== 'COMPLETED') { console.log(`[SUITE-BLOCKED] User ${user.id} tried SUITE but status is ${userProgress?.exerciseStatus || 'null'}`); await scheduleMessage(user.id, user.language === 'WOLOF' ? "Dafa laaj nga tontu laaj bi ci kaw dëbb (audio walla texte) balaa nga dem ci kanam ! 🎙️" : "Tu dois d'abord répondre à l'exercice ci-dessus pour continuer ! 🎙️" ); return; } console.log(`[SUITE-ALLOWED] User ${user.id} advancing from day ${activeEnrollment.currentDay}`); const nextDay = activeEnrollment.currentDay % 1 !== 0 ? Math.floor(activeEnrollment.currentDay) + 1 : activeEnrollment.currentDay + 1; await prisma.enrollment.update({ where: { id: activeEnrollment.id }, data: { currentDay: nextDay } }); await prisma.userProgress.update({ where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }, data: { exerciseStatus: 'PENDING', iterationCount: 0 // Reset iteration count for the new day } }); await whatsappQueue.add('send-content', { userId: user.id, trackId: activeEnrollment.trackId, dayNumber: nextDay }); return; } // Handle APPROFONDIR (Deep Dive Initiation) if (isApprofondir) { const userProgress = await prisma.userProgress.findUnique({ where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } } }); if (userProgress?.exerciseStatus === 'COMPLETED') { // Start the Deep Dive process await prisma.userProgress.update({ where: { id: userProgress.id }, data: { exerciseStatus: 'PENDING_DEEPDIVE' } }); await scheduleMessage(user.id, user.language === 'WOLOF' ? "Baax na ! Wax ma ndox mi nga yor ci sa mbir (njëg, jafe-jafe, njëgëndal, njàngat, etc.) ngir ma gën a deesi njàngat bi :" : "Très bien ! Quelle information précise issue de ton terrain veux-tu ajouter ? (ex: un prix précis, un obstacle, un fournisseur, etc.) :" ); return; } else { await scheduleMessage(user.id, user.language === 'WOLOF' ? "Dafa laaj nga tontu laaj bi ci kaw dëbb balaa nga natt nga tontu !" : "Réponds d'abord à l'exercice principal avant d'approfondir !" ); return; } } // Handle YES/NO Intents if (intent === 'YES' && normalizedText.length < 15) { const userProgress = await prisma.userProgress.findUnique({ where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } } }); if (userProgress?.exerciseStatus === 'COMPLETED') { await scheduleMessage(user.id, user.language === 'WOLOF' ? "Waaw ! Lexon bi mu ngi ñëw..." : "C'est parti ! Voici la suite..."); await whatsappQueue.add('send-content', { userId: user.id, trackId: activeEnrollment.trackId, dayNumber: activeEnrollment.currentDay + 1 }); return; } } if (intent === 'NO' && normalizedText.length < 15) { await scheduleMessage(user.id, user.language === 'WOLOF' ? "Baax na, bu la neexee nga tontu laaj bi." : "Pas de souci, tu peux répondre à l'exercice quand tu es prêt."); return; } // Fallback to Exercise Response if nothing else matched // 🚨 COACHING GUARDRAIL: AI Coach only activated if profile (sector + language) is complete if (!user.activity) { await scheduleMessage(user.id, user.language === 'WOLOF' ? "Danga wara tànn sa mbiru liggeey balaa ñuy tàmbali coaching bi." : "Tu dois d'abord définir ton activité avant que le coach AI ne puisse t'aider."); return; } const pendingProgress = await prisma.userProgress.findFirst({ where: { userId: user.id, exerciseStatus: { in: ['PENDING', 'PENDING_REMEDIATION', 'PENDING_DEEPDIVE'] }, trackId: activeEnrollment.trackId }, }); if (pendingProgress) { const isDeepDiveAction = pendingProgress.exerciseStatus === 'PENDING_DEEPDIVE'; const trackDay = await prisma.trackDay.findFirst({ where: { trackId: activeEnrollment.trackId, dayNumber: activeEnrollment.currentDay } }); if (trackDay) { await scheduleMessage(user.id, user.language === 'WOLOF' ? "⏳ Defar ak sa tontu..." : "⏳ Analyse de votre réponse..."); // Update iteration count if it's a deep dive let currentIterationCount = pendingProgress.iterationCount || 0; if (isDeepDiveAction) { currentIterationCount += 1; await prisma.userProgress.update({ where: { id: pendingProgress.id }, data: { iterationCount: currentIterationCount } // Save the increment }); } // 🚨 Store the iterative response so the Pitch Deck AI can use it await prisma.response.create({ data: { enrollmentId: activeEnrollment.id, userId: user.id, dayNumber: activeEnrollment.currentDay, content: text } }); // Fetch previous responses to provide context to the AI Coach const previousResponsesData = await prisma.response.findMany({ where: { userId: user.id, enrollmentId: activeEnrollment.id }, orderBy: { dayNumber: 'asc' }, take: 5 // Keep context size reasonable }); const previousResponses = previousResponsesData.map(r => ({ day: r.dayNumber, response: r.content })); await whatsappQueue.add('generate-feedback', { userId: user.id, text, trackId: activeEnrollment.trackId, trackDayId: trackDay.id, exercisePrompt: trackDay.exercisePrompt || '', lessonText: trackDay.lessonText || '', exerciseCriteria: trackDay.exerciseCriteria, pendingProgressId: pendingProgress.id, enrollmentId: activeEnrollment.id, currentDay: activeEnrollment.currentDay, totalDays: activeEnrollment.track.duration, language: user.language, // NEW EXPERT CONTEXT userActivity: user.activity, userRegion: user.city, previousResponses, // DEEP DIVE PARAMETERS isDeepDive: isDeepDiveAction, iterationCount: currentIterationCount }, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } }); return; } } // Handle daily response (Fallback if no PENDING found earlier) console.log(`${traceId} User ${user.id} fallback daily response to day ${activeEnrollment.currentDay}`); await prisma.response.create({ data: { enrollmentId: activeEnrollment.id, userId: user.id, dayNumber: activeEnrollment.currentDay, content: text } }); const trackDayFallback = await prisma.trackDay.findFirst({ where: { trackId: activeEnrollment.trackId, dayNumber: activeEnrollment.currentDay } }); if (trackDayFallback) { // 🚨 Guardrail: Contenu Vide / Gibberish 🚨 const wordCount = text.trim().split(/\s+/).length; if (wordCount < 3 || text.length < 5) { console.log(`${traceId} Guardrail: Input too short or potential gibberish: "${text}"`); await scheduleMessage(user.id, user.language === 'WOLOF' ? "Ma déggul li nga wax mbir mi... Mën nga ko gën a firi ci ab kàddu gatt (3-4 kàddu) ?" : "Je n'ai pas bien compris ton activité. Peux-tu me réexpliquer en quelques mots ce que tu vends et à qui ?"); return; } // 🚨 Guardrail: Enrollment Priority 🚨 if (!user.activity || !user.language) { console.log(`${traceId} Blocking AI feedback: Enrollment incomplete for User ${user.id}`); await scheduleMessage(user.id, user.language === 'WOLOF' ? "Baax na, waaye laaj bi des na... Bindal 'INSCRIPTION' ngir tàmbali." : "C'est noté, mais il faut d'abord terminer ton inscription. Envoie 'INSCRIPTION' pour commencer."); return; } // Fetch previous responses to provide context to the AI Coach const previousResponsesData = await prisma.response.findMany({ where: { userId: user.id, enrollmentId: activeEnrollment.id }, orderBy: { dayNumber: 'asc' }, take: 5 }); const previousResponses = previousResponsesData.map(r => ({ day: r.dayNumber, response: r.content })); await whatsappQueue.add('generate-feedback', { userId: user.id, text, trackId: activeEnrollment.trackId, trackDayId: trackDayFallback.id, exercisePrompt: trackDayFallback.exercisePrompt || '', lessonText: trackDayFallback.lessonText || '', exerciseCriteria: trackDayFallback.exerciseCriteria, enrollmentId: activeEnrollment.id, currentDay: activeEnrollment.currentDay, totalDays: activeEnrollment.track.duration, language: user.language, // NEW EXPERT CONTEXT userActivity: user.activity, userRegion: user.city, previousResponses }); return; } await scheduleMessage(user.id, user.language === 'WOLOF' ? "Baax na ! Yónnee *SUITE* ngir dem ci kanam walla tontul laaj bi ci kaw." : "✅ Message reçu ! Envoie *SUITE* pour avancer ou réponds à l'exercice ci-dessus." ); return; } // 4. Default: fallback for generic unknown messages (not in onboarding, not in active enrollment) console.log(`${traceId} Unknown command from user ${user.id}: "${normalizedText}"`); await scheduleMessage(user.id, user.language === 'WOLOF' ? "Bañ ma dégg. Yónnee *INSCRIPTION* ngir tàmbalee ci kanam walla bind *SUITE*." : "Je n'ai pas compris. Envoie *INSCRIPTION* pour recommencer ou *SUITE* pour avancer." ); } }