import axios from 'axios'; import { logger } from '../logger'; export interface WhatsAppMessage { to: string; text?: string; templateName?: string; templateLanguage?: string; } export class WhatsAppService { private baseUrl = process.env.WHATSAPP_GRAPH_URL || `https://graph.facebook.com/${process.env.META_GRAPH_API_VERSION || 'v22.0'}`; /** * Sends a direct text message or template via WhatsApp Cloud API */ async sendMessage(config: { accessToken: string; phoneNumberId: string }, message: WhatsAppMessage) { try { const url = `${this.baseUrl}/${config.phoneNumberId}/messages`; let payload: any; if (message.templateName) { payload = { messaging_product: 'whatsapp', recipient_type: 'individual', to: message.to, type: 'template', template: { name: message.templateName, language: { code: message.templateLanguage || 'fr' } } }; } else { payload = { messaging_product: 'whatsapp', recipient_type: 'individual', to: message.to, type: 'text', text: { body: message.text || '' } }; } const response = await axios.post(url, payload, { headers: { 'Authorization': `Bearer ${config.accessToken}`, 'Content-Type': 'application/json' } }); return { id: response.data.messages?.[0]?.id, status: 'SENT' }; } catch (err: any) { logger.error({ error: err.response?.data || err.message, to: message.to, template: message.templateName }, '[WHATSAPP_SERVICE] Failed to send message'); throw err; } } /** * Bulk send utility */ async sendBulk(config: { accessToken: string; phoneNumberId: string }, messages: WhatsAppMessage[]) { const results = { sent: 0, failed: 0 }; // We use sequential or limited concurrency to avoid Meta rate limits for (const msg of messages) { try { await this.sendMessage(config, msg); results.sent++; } catch (err) { results.failed++; } } return results; } /** * Fetches message templates from Meta Graph API */ async fetchMetaTemplates(config: { accessToken: string; wabaId: string }) { try { const url = `${this.baseUrl}/${config.wabaId}/message_templates`; const response = await axios.get(url, { headers: { 'Authorization': `Bearer ${config.accessToken}` }, params: { limit: 100 } }); return response.data.data; } catch (err: any) { logger.error({ error: err.response?.data || err.message, wabaId: config.wabaId }, '[WHATSAPP_SERVICE] Failed to fetch templates'); throw err; } } /** * Submits a new message template to Meta for approval */ async createMetaTemplate(config: { accessToken: string; wabaId: string }, template: any) { try { const url = `${this.baseUrl}/${config.wabaId}/message_templates`; const response = await axios.post(url, template, { headers: { 'Authorization': `Bearer ${config.accessToken}`, 'Content-Type': 'application/json' } }); return response.data; } catch (err: any) { logger.error({ error: err.response?.data || err.message, wabaId: config.wabaId }, '[WHATSAPP_SERVICE] Failed to create template'); throw err; } } /** * Initiates WhatsApp Business phone number registration. * Meta sends an OTP to the phone number to verify ownership. */ async registerPhoneNumber(config: { accessToken: string }, phoneNumberId: string, pin: string = '000000') { try { const url = `${this.baseUrl}/${phoneNumberId}/register`; const response = await axios.post(url, { messaging_product: 'whatsapp', pin, }, { headers: { 'Authorization': `Bearer ${config.accessToken}`, 'Content-Type': 'application/json', } }); return response.data; } catch (err: any) { logger.error({ error: err.response?.data || err.message, phoneNumberId }, '[WHATSAPP_SERVICE] registerPhoneNumber failed'); throw err; } } /** * Verifies the OTP code Meta sent during phone number registration. */ async verifyPhoneNumber(config: { accessToken: string }, phoneNumberId: string, code: string) { try { const url = `${this.baseUrl}/${phoneNumberId}/verify_code`; const response = await axios.post(url, { code }, { headers: { 'Authorization': `Bearer ${config.accessToken}`, 'Content-Type': 'application/json', } }); return response.data; } catch (err: any) { logger.error({ error: err.response?.data || err.message, phoneNumberId }, '[WHATSAPP_SERVICE] verifyPhoneNumber failed'); throw err; } } /** * Resolves a Meta Media ID into a permanent or temporary URL. * This abstracts the two-step download process. */ async resolveMediaUrl(config: { accessToken: string }, mediaId: string): Promise { try { const url = `${this.baseUrl}/${mediaId}`; const response = await axios.get(url, { headers: { 'Authorization': `Bearer ${config.accessToken}` } }); return response.data.url; } catch (err: any) { logger.error({ error: err.response?.data || err.message, mediaId }, '[WHATSAPP_SERVICE] Failed to resolve media URL'); throw err; } } /** * Orchestrates the AI response loop and notifications for an incoming message */ async handleIncomingMessage(phone: string, text: string, audioUrl?: string, imageUrl?: string, _videoId?: string, organizationId?: string) { if (!organizationId) { logger.warn({ phone }, '[WHATSAPP_SERVICE] Cannot handle message without organizationId'); return; } try { const { EntityResolver } = await import('./EntityResolver'); const { aiService } = await import('./ai'); const { pushService } = await import('./push'); const { decryptSecrets } = await import('./organization'); const { prisma } = await import('./prisma'); // 1. Resolve Entities (Standardized logic) const { user, contact, organization } = await EntityResolver.resolve(phone, organizationId); // 2. Decrypt config if present const config = organization ? decryptSecrets(organization) : null; const accessToken = config?.systemUserToken; // 3. Auto-resolve Media IDs if they are IDs instead of URLs let resolvedAudio = audioUrl; let resolvedImage = imageUrl; if (accessToken) { if (audioUrl && !audioUrl.startsWith('http')) { resolvedAudio = await this.resolveMediaUrl({ accessToken }, audioUrl).catch(() => audioUrl); } if (imageUrl && !imageUrl.startsWith('http')) { resolvedImage = await this.resolveMediaUrl({ accessToken }, imageUrl).catch(() => imageUrl); } } // 4. Log Message (Non-blocking) if (user || contact) { prisma.message.create({ data: { content: text || '[Media]', mediaUrl: resolvedAudio || resolvedImage, direction: 'INBOUND', userId: user?.id, contactId: contact?.id, organizationId } }).catch(err => logger.warn({ err }, '[WHATSAPP_SERVICE] Failed to log message')); } // 5. Process via AI Closing Engine const { response } = await aiService.handleCrmConversation(phone, organizationId, text || '[Médias Reçus]'); // 6. Trigger Push Notification to the team await pushService.notifyOrganization( organizationId, "Nouveau message WhatsApp", `Le client ${phone} a répondu : "${(text || '').substring(0, 50)}..."` ).catch(e => logger.warn({ e }, "[PUSH] Failed to notify")); // 7. Send automated response if possible const phoneNumberId = organization?.phoneNumbers?.[0]?.id; if (organization && phoneNumberId && accessToken) { await this.sendMessage({ accessToken: accessToken, phoneNumberId }, { to: phone, text: response }); } } catch (err) { logger.error({ err, phone }, '[WHATSAPP_SERVICE] handleIncomingMessage failed'); } } } export const whatsappService = new WhatsAppService();