CognxSafeTrack commited on
Commit ·
1dec751
1
Parent(s): 1403ab3
feat(ux): interactive buttons for language choice + LIST menu for sector selection (8 sectors FR/Wolof)
Browse files- apps/api/src/services/queue.ts +24 -17
- apps/api/src/services/whatsapp.ts +84 -20
- apps/whatsapp-worker/src/index.ts +15 -1
apps/api/src/services/queue.ts
CHANGED
|
@@ -15,27 +15,34 @@ const connection = process.env.REDIS_URL
|
|
| 15 |
export const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
|
| 16 |
|
| 17 |
export async function scheduleMessage(userId: string, text: string, delayMs: number = 0) {
|
| 18 |
-
await whatsappQueue.add('send-message', {
|
| 19 |
-
userId,
|
| 20 |
-
text
|
| 21 |
-
}, {
|
| 22 |
-
delay: delayMs
|
| 23 |
-
});
|
| 24 |
}
|
| 25 |
|
| 26 |
export async function scheduleTrackDay(userId: string, trackId: string, dayNumber: number, delayMs: number = 0) {
|
| 27 |
-
await whatsappQueue.add('send-content', {
|
| 28 |
-
userId,
|
| 29 |
-
trackId,
|
| 30 |
-
dayNumber
|
| 31 |
-
}, {
|
| 32 |
-
delay: delayMs
|
| 33 |
-
});
|
| 34 |
}
|
| 35 |
|
| 36 |
export async function enrollUser(userId: string, trackId: string) {
|
| 37 |
-
await whatsappQueue.add('enroll-user', {
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
|
|
|
|
|
| 15 |
export const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
|
| 16 |
|
| 17 |
export async function scheduleMessage(userId: string, text: string, delayMs: number = 0) {
|
| 18 |
+
await whatsappQueue.add('send-message', { userId, text }, { delay: delayMs });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
export async function scheduleTrackDay(userId: string, trackId: string, dayNumber: number, delayMs: number = 0) {
|
| 22 |
+
await whatsappQueue.add('send-content', { userId, trackId, dayNumber }, { delay: delayMs });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
export async function enrollUser(userId: string, trackId: string) {
|
| 26 |
+
await whatsappQueue.add('enroll-user', { userId, trackId });
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/** Send a WhatsApp interactive BUTTON message (max 3 buttons). */
|
| 30 |
+
export async function scheduleInteractiveButtons(
|
| 31 |
+
userId: string,
|
| 32 |
+
bodyText: string,
|
| 33 |
+
buttons: Array<{ id: string; title: string }>
|
| 34 |
+
) {
|
| 35 |
+
await whatsappQueue.add('send-interactive-buttons', { userId, bodyText, buttons });
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/** Send a WhatsApp interactive LIST message (up to 10 rows, grouped in sections). */
|
| 39 |
+
export async function scheduleInteractiveList(
|
| 40 |
+
userId: string,
|
| 41 |
+
headerText: string,
|
| 42 |
+
bodyText: string,
|
| 43 |
+
buttonLabel: string,
|
| 44 |
+
sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }>
|
| 45 |
+
) {
|
| 46 |
+
await whatsappQueue.add('send-interactive-list', { userId, headerText, bodyText, buttonLabel, sections });
|
| 47 |
}
|
| 48 |
+
|
apps/api/src/services/whatsapp.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { prisma } from './prisma';
|
| 2 |
-
import { scheduleMessage, enrollUser, whatsappQueue, scheduleTrackDay } from './queue';
|
| 3 |
import { QuotaExceededError } from './ai/openai-provider';
|
| 4 |
|
| 5 |
export class WhatsAppService {
|
|
@@ -12,11 +12,14 @@ export class WhatsAppService {
|
|
| 12 |
|
| 13 |
if (!user) {
|
| 14 |
if (normalizedText === 'INSCRIPTION') {
|
| 15 |
-
user = await prisma.user.create({
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
return;
|
| 21 |
} else {
|
| 22 |
console.log(`Unregistered user ${phone} sent a message. Need INSCRIPTION.`);
|
|
@@ -32,7 +35,13 @@ export class WhatsAppService {
|
|
| 32 |
where: { id: user.id },
|
| 33 |
data: { city: null, activity: null }
|
| 34 |
});
|
| 35 |
-
await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
return;
|
| 37 |
}
|
| 38 |
|
|
@@ -169,35 +178,90 @@ export class WhatsAppService {
|
|
| 169 |
|
| 170 |
// 2. Check Onboarding State (Missing Language -> Missing Activity)
|
| 171 |
if (!user.city) {
|
| 172 |
-
//
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
| 175 |
user = await prisma.user.update({
|
| 176 |
where: { id: user.id },
|
| 177 |
-
data: { language: lang, city: 'SET' }
|
| 178 |
});
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
return;
|
| 185 |
} else {
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
return;
|
| 188 |
}
|
| 189 |
}
|
| 190 |
|
| 191 |
if (!user.activity) {
|
| 192 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
user = await prisma.user.update({
|
| 194 |
where: { id: user.id },
|
| 195 |
-
data: { activity
|
| 196 |
});
|
| 197 |
|
| 198 |
const welcomeMsg = user.language === 'FR'
|
| 199 |
-
? `
|
| 200 |
-
: `
|
| 201 |
|
| 202 |
await scheduleMessage(user.id, welcomeMsg);
|
| 203 |
|
|
|
|
| 1 |
import { prisma } from './prisma';
|
| 2 |
+
import { scheduleMessage, enrollUser, whatsappQueue, scheduleTrackDay, scheduleInteractiveButtons, scheduleInteractiveList } from './queue';
|
| 3 |
import { QuotaExceededError } from './ai/openai-provider';
|
| 4 |
|
| 5 |
export class WhatsAppService {
|
|
|
|
| 12 |
|
| 13 |
if (!user) {
|
| 14 |
if (normalizedText === 'INSCRIPTION') {
|
| 15 |
+
user = await prisma.user.create({ data: { phone } });
|
| 16 |
+
await scheduleInteractiveButtons(user.id,
|
| 17 |
+
"Bienvenue sur Xamle ! 🎉\nChoisissez votre langue / Tànnal sa làkk :",
|
| 18 |
+
[
|
| 19 |
+
{ id: 'LANG_FR', title: 'Français 🇫🇷' },
|
| 20 |
+
{ id: 'LANG_WO', title: 'Wolof 🇸🇳' }
|
| 21 |
+
]
|
| 22 |
+
);
|
| 23 |
return;
|
| 24 |
} else {
|
| 25 |
console.log(`Unregistered user ${phone} sent a message. Need INSCRIPTION.`);
|
|
|
|
| 35 |
where: { id: user.id },
|
| 36 |
data: { city: null, activity: null }
|
| 37 |
});
|
| 38 |
+
await scheduleInteractiveButtons(user.id,
|
| 39 |
+
"Réinitialisation réussie – choisissez votre langue / Tànnal sa làkk :",
|
| 40 |
+
[
|
| 41 |
+
{ id: 'LANG_FR', title: 'Français 🇫🇷' },
|
| 42 |
+
{ id: 'LANG_WO', title: 'Wolof 🇸🇳' }
|
| 43 |
+
]
|
| 44 |
+
);
|
| 45 |
return;
|
| 46 |
}
|
| 47 |
|
|
|
|
| 178 |
|
| 179 |
// 2. Check Onboarding State (Missing Language -> Missing Activity)
|
| 180 |
if (!user.city) {
|
| 181 |
+
// Accept button ID or legacy text
|
| 182 |
+
const isLangFR = normalizedText === 'LANG_FR' || normalizedText === '1';
|
| 183 |
+
const isLangWO = normalizedText === 'LANG_WO' || normalizedText === '2';
|
| 184 |
+
|
| 185 |
+
if (isLangFR || isLangWO) {
|
| 186 |
+
const lang = isLangFR ? 'FR' : 'WOLOF';
|
| 187 |
user = await prisma.user.update({
|
| 188 |
where: { id: user.id },
|
| 189 |
+
data: { language: lang, city: 'SET' }
|
| 190 |
});
|
| 191 |
|
| 192 |
+
// Sector LIST — 8 common business types
|
| 193 |
+
const isWolof = lang === 'WOLOF';
|
| 194 |
+
await scheduleInteractiveList(
|
| 195 |
+
user.id,
|
| 196 |
+
isWolof ? 'Ban mbir ngay def ?' : 'Ton secteur d\'activité',
|
| 197 |
+
isWolof
|
| 198 |
+
? 'Tànnal sa mbir ci suuf ngir waajal sa njàng :'
|
| 199 |
+
: 'Choisis ton secteur pour personnaliser ta formation :',
|
| 200 |
+
isWolof ? 'Tànnal' : 'Choisir',
|
| 201 |
+
[{
|
| 202 |
+
title: isWolof ? 'Mbir yi' : 'Secteurs',
|
| 203 |
+
rows: [
|
| 204 |
+
{ id: 'SEC_COMMERCE', title: isWolof ? '🛒 Njaay / Commerce' : '🛒 Commerce / Vente', description: isWolof ? 'Njaay yëgël, boutik...' : 'Boutique, marché, revente...' },
|
| 205 |
+
{ id: 'SEC_AGRI', title: isWolof ? '🌾 Mbay / Agriculture' : '🌾 Agriculture / Élevage', description: isWolof ? 'Mbay, génaaw-génaaw...' : 'Cultures, ferme, élevage...' },
|
| 206 |
+
{ id: 'SEC_FOOD', title: isWolof ? '🍲 Lekk / Restauration' : '🍲 Alimentation / Restauration', description: isWolof ? 'Dëkk, thiébou djeun...' : 'Cuisine, livraison, snack...' },
|
| 207 |
+
{ id: 'SEC_TECH', title: isWolof ? '💻 Tech / Digital' : '💻 Tech / Digital', description: isWolof ? 'App, internet, design...' : 'App, web, réseaux sociaux...' },
|
| 208 |
+
{ id: 'SEC_BEAUTE', title: isWolof ? '💅 Rafet / Beauté' : '💅 Beauté / Bien-être', description: isWolof ? 'Sèp bu rafet...' : 'Salon, cosmétique, soins...' },
|
| 209 |
+
{ id: 'SEC_COUTURE', title: isWolof ? '🧵 Couture / Mode' : '🧵 Couture / Mode', description: isWolof ? 'Njiit, fac...' : 'Confection, stylisme...' },
|
| 210 |
+
{ id: 'SEC_TRANSPORT', title: isWolof ? '🚗 Yëgël / Transport' : '🚗 Transport / Livraison', description: isWolof ? 'Taxi, delivery...' : 'Moto, camion, coursier...' },
|
| 211 |
+
{ id: 'SEC_AUTRE', title: isWolof ? '💡 Yeneen' : '💡 Autre secteur', description: isWolof ? 'Wax ma ci kanam...' : 'Décris ton activité ensuite' }
|
| 212 |
+
]
|
| 213 |
+
}]
|
| 214 |
+
);
|
| 215 |
return;
|
| 216 |
} else {
|
| 217 |
+
// Re-send the language buttons instead of plain text
|
| 218 |
+
await scheduleInteractiveButtons(user.id,
|
| 219 |
+
user.language === 'WOLOF'
|
| 220 |
+
? 'Tànnal sa làkk / Choisissez votre langue :'
|
| 221 |
+
: 'Veuillez choisir votre langue / Tànnal sa làkk :',
|
| 222 |
+
[
|
| 223 |
+
{ id: 'LANG_FR', title: 'Français 🇫🇷' },
|
| 224 |
+
{ id: 'LANG_WO', title: 'Wolof 🇸🇳' }
|
| 225 |
+
]
|
| 226 |
+
);
|
| 227 |
return;
|
| 228 |
}
|
| 229 |
}
|
| 230 |
|
| 231 |
if (!user.activity) {
|
| 232 |
+
// Resolve sector LIST reply IDs → human-readable label
|
| 233 |
+
const SECTOR_LABELS: Record<string, { fr: string; wo: string }> = {
|
| 234 |
+
SEC_COMMERCE: { fr: 'Commerce / Vente', wo: 'Njaay' },
|
| 235 |
+
SEC_AGRI: { fr: 'Agriculture / Élevage', wo: 'Mbay' },
|
| 236 |
+
SEC_FOOD: { fr: 'Alimentation / Restauration', wo: 'Lekk / Restauration' },
|
| 237 |
+
SEC_TECH: { fr: 'Tech / Digital', wo: 'Tech / Digital' },
|
| 238 |
+
SEC_BEAUTE: { fr: 'Beauté / Bien-être', wo: 'Rafet' },
|
| 239 |
+
SEC_COUTURE: { fr: 'Couture / Mode', wo: 'Couture' },
|
| 240 |
+
SEC_TRANSPORT: { fr: 'Transport / Livraison', wo: 'Transport / Yëgël' },
|
| 241 |
+
};
|
| 242 |
+
|
| 243 |
+
if (normalizedText === 'SEC_AUTRE') {
|
| 244 |
+
// Ask them to describe in their own words
|
| 245 |
+
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 246 |
+
? 'Waaw ! Wax ma ban mbir ngay def ci ab kàddu gatt :'
|
| 247 |
+
: 'Parfait ! Décris ton activité en quelques mots :'
|
| 248 |
+
);
|
| 249 |
+
return;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
const sectorLabel = SECTOR_LABELS[normalizedText];
|
| 253 |
+
const activity = sectorLabel
|
| 254 |
+
? (user.language === 'WOLOF' ? sectorLabel.wo : sectorLabel.fr)
|
| 255 |
+
: text.trim(); // free-form text fallback
|
| 256 |
+
|
| 257 |
user = await prisma.user.update({
|
| 258 |
where: { id: user.id },
|
| 259 |
+
data: { activity }
|
| 260 |
});
|
| 261 |
|
| 262 |
const welcomeMsg = user.language === 'FR'
|
| 263 |
+
? `Parfait ! Secteur noté : *${activity}*.\nJe t'inscris à ta formation personnalisée !`
|
| 264 |
+
: `Baax na ! Bind nanu la ci: *${activity}*.\nLéegi dinanu la dugal ci njàng mi !`;
|
| 265 |
|
| 266 |
await scheduleMessage(user.id, welcomeMsg);
|
| 267 |
|
apps/whatsapp-worker/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ dns.setDefaultResultOrder('ipv4first');
|
|
| 4 |
import { Worker, Job } from 'bullmq';
|
| 5 |
import dotenv from 'dotenv';
|
| 6 |
import { PrismaClient } from '@prisma/client';
|
| 7 |
-
import { sendTextMessage, sendDocumentMessage, downloadMedia } from './whatsapp-cloud';
|
| 8 |
|
| 9 |
dotenv.config();
|
| 10 |
|
|
@@ -36,6 +36,20 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
|
|
| 36 |
console.warn(`[WORKER] User ${userId} not found or missing phone — skipping send.`);
|
| 37 |
}
|
| 38 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
else if (job.name === 'enroll-user') {
|
| 40 |
const { userId, trackId } = job.data;
|
| 41 |
|
|
|
|
| 4 |
import { Worker, Job } from 'bullmq';
|
| 5 |
import dotenv from 'dotenv';
|
| 6 |
import { PrismaClient } from '@prisma/client';
|
| 7 |
+
import { sendTextMessage, sendDocumentMessage, downloadMedia, sendInteractiveButtonMessage, sendInteractiveListMessage } from './whatsapp-cloud';
|
| 8 |
|
| 9 |
dotenv.config();
|
| 10 |
|
|
|
|
| 36 |
console.warn(`[WORKER] User ${userId} not found or missing phone — skipping send.`);
|
| 37 |
}
|
| 38 |
}
|
| 39 |
+
else if (job.name === 'send-interactive-buttons') {
|
| 40 |
+
const { userId, bodyText, buttons } = job.data;
|
| 41 |
+
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 42 |
+
if (user?.phone) {
|
| 43 |
+
await sendInteractiveButtonMessage(user.phone, bodyText, buttons);
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
else if (job.name === 'send-interactive-list') {
|
| 47 |
+
const { userId, headerText, bodyText, buttonLabel, sections } = job.data;
|
| 48 |
+
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 49 |
+
if (user?.phone) {
|
| 50 |
+
await sendInteractiveListMessage(user.phone, headerText, bodyText, buttonLabel, sections);
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
else if (job.name === 'enroll-user') {
|
| 54 |
const { userId, trackId } = job.data;
|
| 55 |
|