edtech / apps /api /src /services /whatsapp.ts
CognxSafeTrack
feat: stabilize AI engine, harden Zod schemas, and implement UX guardrails
77c96f8
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 <TrackId> <DayNumber>");
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<string, { fr: string; wo: string }> = {
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."
);
}
}