edtech / apps /api /src /services /whatsapp.ts
CognxSafeTrack
fix: upgrade Meta Graph API from deprecated v18/v19 to v22.0
b25d16e
Raw History Blame Contribute Delete
9.96 kB
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<string> {
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();