Download apps/api/src/services/whatsapp.ts from safetrack/edtech: direct link, hf CLI and curl.
- Browser
- Download file 9.96 kB
-
https://huggingface.co/spaces/safetrack/edtech/resolve/main/apps/api/src/services/whatsapp.ts
- Command line
-
hf download hf://spaces/safetrack/edtech/apps/api/src/services/whatsapp.ts
-
curl -L -o whatsapp.ts https://huggingface.co/spaces/safetrack/edtech/resolve/main/apps/api/src/services/whatsapp.ts
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(); | |