Migración desde michangarrito/apps/whatsapp-service - Estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:27:00 -06:00
parent 2937c607c1
commit 7ca75455bc
22 changed files with 7039 additions and 3 deletions

33
.dockerignore Normal file
View File

@ -0,0 +1,33 @@
# Dependencies
node_modules
npm-debug.log
# Build output
dist
# IDE
.idea
.vscode
*.swp
*.swo
# Environment
.env
.env.*
!.env.example
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Documentation
README.md
# Tests
test
*.spec.ts

24
.env Normal file
View File

@ -0,0 +1,24 @@
# MiChangarrito WhatsApp Service - Environment Variables
# Server
PORT=3143
NODE_ENV=development
# Backend API
BACKEND_API_URL=http://localhost:3141
BACKEND_API_PREFIX=/api/v1
# WhatsApp Business API (Meta Cloud API)
# Configurar con credenciales reales para produccion
WHATSAPP_ACCESS_TOKEN=placeholder_token
WHATSAPP_PHONE_NUMBER_ID=placeholder_id
WHATSAPP_VERIFY_TOKEN=michangarrito_webhook_verify_2025
WHATSAPP_BUSINESS_ID=placeholder_business_id
# LLM Configuration
OPENAI_API_KEY=placeholder_key
LLM_MODEL=gpt-4o-mini
LLM_BASE_URL=https://api.openai.com/v1
# Logging
LOG_LEVEL=debug

19
.env.example Normal file
View File

@ -0,0 +1,19 @@
# Server
PORT=3143
NODE_ENV=development
CORS_ORIGIN=*
# WhatsApp Business API (Meta)
WHATSAPP_ACCESS_TOKEN=your_access_token_here
WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id
WHATSAPP_VERIFY_TOKEN=your_webhook_verify_token
WHATSAPP_APP_SECRET=your_app_secret
# LLM Configuration
OPENAI_API_KEY=your_openai_api_key
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o-mini
# Backend API (for product/order data)
BACKEND_API_URL=http://localhost:3141
BACKEND_API_KEY=internal_api_key

87
Dockerfile Normal file
View File

@ -0,0 +1,87 @@
# =============================================================================
# MiChangarrito - WhatsApp Service Dockerfile
# =============================================================================
# Multi-stage build for NestJS WhatsApp integration
# Puerto: 3143
# =============================================================================
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build application
RUN npm run build
# Production stage
FROM node:20-alpine AS production
# Labels
LABEL maintainer="ISEM"
LABEL description="MiChangarrito WhatsApp Service"
LABEL version="1.0.0"
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install only production dependencies
RUN npm ci --only=production && npm cache clean --force
# Copy built application
COPY --from=builder /app/dist ./dist
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001 -G nodejs && \
chown -R nestjs:nodejs /app
USER nestjs
# Environment
ENV NODE_ENV=production
ENV PORT=3143
# Expose port
EXPOSE 3143
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3143/health || exit 1
# Start application
CMD ["node", "dist/main"]
# Development stage
FROM node:20-alpine AS development
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies
RUN npm ci
# Copy source code
COPY . .
# Environment
ENV NODE_ENV=development
ENV PORT=3143
# Expose port
EXPOSE 3143
# Start in development mode
CMD ["npm", "run", "start:dev"]

View File

@ -1,3 +0,0 @@
# michangarrito-whatsapp-service-v2
WhatsApp Service de michangarrito - Workspace V2

8
nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

4785
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "michangarrito-whatsapp-service",
"version": "1.0.0",
"description": "MiChangarrito - WhatsApp Business API Integration Service",
"author": "ISEM",
"private": true,
"license": "MIT",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"test": "jest"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/swagger": "^7.2.0",
"axios": "^1.6.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^7.1.0",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/testing": "^10.3.0",
"@types/express": "^4.17.21",
"@types/node": "^20.10.8",
"typescript": "^5.3.3"
}
}

20
src/app.module.ts Normal file
View File

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CommonModule } from './common/common.module';
import { WebhookModule } from './webhook/webhook.module';
import { WhatsAppModule } from './whatsapp/whatsapp.module';
import { LlmModule } from './llm/llm.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env', '../.env'],
}),
CommonModule,
WebhookModule,
WhatsAppModule,
LlmModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,11 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CredentialsProviderService } from './credentials-provider.service';
@Global()
@Module({
imports: [ConfigModule],
providers: [CredentialsProviderService],
exports: [CredentialsProviderService],
})
export class CommonModule {}

View File

@ -0,0 +1,249 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
export interface WhatsAppCredentials {
accessToken: string;
phoneNumberId: string;
businessAccountId?: string;
verifyToken?: string;
isFromPlatform: boolean;
tenantId?: string;
}
export interface LLMConfig {
apiKey: string;
provider: string;
model: string;
maxTokens: number;
temperature: number;
baseUrl: string;
systemPrompt?: string;
isFromPlatform: boolean;
tenantId?: string;
}
@Injectable()
export class CredentialsProviderService {
private readonly logger = new Logger(CredentialsProviderService.name);
private readonly backendClient: AxiosInstance;
private readonly cache = new Map<string, { data: any; expiresAt: number }>();
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutos
constructor(private readonly configService: ConfigService) {
const backendUrl = this.configService.get('BACKEND_URL', 'http://localhost:3141/api/v1');
this.backendClient = axios.create({
baseURL: backendUrl,
timeout: 5000,
});
}
/**
* Obtiene las credenciales de WhatsApp para un tenant
* Si no hay credenciales del tenant, retorna las de plataforma
*/
async getWhatsAppCredentials(tenantId?: string): Promise<WhatsAppCredentials> {
// Si no hay tenantId, usar plataforma directamente
if (!tenantId) {
return this.getPlatformWhatsAppCredentials();
}
// Intentar obtener del cache
const cacheKey = `whatsapp:${tenantId}`;
const cached = this.getFromCache(cacheKey);
if (cached) {
return cached;
}
try {
// Intentar obtener del backend
const { data } = await this.backendClient.get(
`/internal/integrations/${tenantId}/whatsapp`,
{
headers: {
'X-Internal-Key': this.configService.get('INTERNAL_API_KEY'),
},
},
);
if (data.configured && data.credentials) {
const credentials: WhatsAppCredentials = {
accessToken: data.credentials.accessToken,
phoneNumberId: data.credentials.phoneNumberId,
businessAccountId: data.credentials.businessAccountId,
verifyToken: data.credentials.verifyToken,
isFromPlatform: false,
tenantId,
};
this.setCache(cacheKey, credentials);
return credentials;
}
} catch (error) {
this.logger.warn(
`Failed to get WhatsApp credentials for tenant ${tenantId}, using platform: ${error.message}`,
);
}
// Fallback a plataforma
return this.getPlatformWhatsAppCredentials();
}
/**
* Obtiene las credenciales de WhatsApp de plataforma
*/
getPlatformWhatsAppCredentials(): WhatsAppCredentials {
return {
accessToken: this.configService.get('WHATSAPP_ACCESS_TOKEN'),
phoneNumberId: this.configService.get('WHATSAPP_PHONE_NUMBER_ID'),
businessAccountId: this.configService.get('WHATSAPP_BUSINESS_ACCOUNT_ID'),
verifyToken: this.configService.get('WHATSAPP_VERIFY_TOKEN'),
isFromPlatform: true,
};
}
/**
* Obtiene la configuración LLM para un tenant
* Si no hay config del tenant, retorna la de plataforma
*/
async getLLMConfig(tenantId?: string): Promise<LLMConfig> {
// Si no hay tenantId, usar plataforma directamente
if (!tenantId) {
return this.getPlatformLLMConfig();
}
// Intentar obtener del cache
const cacheKey = `llm:${tenantId}`;
const cached = this.getFromCache(cacheKey);
if (cached) {
return cached;
}
try {
// Intentar obtener del backend
const { data } = await this.backendClient.get(
`/internal/integrations/${tenantId}/llm`,
{
headers: {
'X-Internal-Key': this.configService.get('INTERNAL_API_KEY'),
},
},
);
if (data.configured && data.credentials) {
const config: LLMConfig = {
apiKey: data.credentials.apiKey,
provider: data.provider,
model: data.config?.model || 'gpt-4o-mini',
maxTokens: data.config?.maxTokens || 1000,
temperature: data.config?.temperature || 0.7,
baseUrl: data.config?.baseUrl || this.getDefaultBaseUrl(data.provider),
systemPrompt: data.config?.systemPrompt,
isFromPlatform: false,
tenantId,
};
this.setCache(cacheKey, config);
return config;
}
} catch (error) {
this.logger.warn(
`Failed to get LLM config for tenant ${tenantId}, using platform: ${error.message}`,
);
}
// Fallback a plataforma
return this.getPlatformLLMConfig();
}
/**
* Obtiene la configuración LLM de plataforma
*/
getPlatformLLMConfig(): LLMConfig {
const provider = this.configService.get('LLM_PROVIDER', 'openai');
return {
apiKey: this.configService.get('OPENAI_API_KEY') || this.configService.get('LLM_API_KEY'),
provider,
model: this.configService.get('LLM_MODEL', 'gpt-4o-mini'),
maxTokens: parseInt(this.configService.get('LLM_MAX_TOKENS', '1000')),
temperature: parseFloat(this.configService.get('LLM_TEMPERATURE', '0.7')),
baseUrl: this.configService.get('LLM_BASE_URL', this.getDefaultBaseUrl(provider)),
isFromPlatform: true,
};
}
/**
* Resuelve el tenantId desde un phoneNumberId de webhook
*/
async resolveTenantFromPhoneNumberId(phoneNumberId: string): Promise<string | null> {
// Si es el número de plataforma, retornar null
const platformPhoneNumberId = this.configService.get('WHATSAPP_PHONE_NUMBER_ID');
if (phoneNumberId === platformPhoneNumberId) {
return null;
}
// Intentar obtener del cache
const cacheKey = `phoneid:${phoneNumberId}`;
const cached = this.getFromCache(cacheKey);
if (cached) {
return cached;
}
try {
const { data } = await this.backendClient.get(
`/internal/integrations/resolve-tenant/${phoneNumberId}`,
{
headers: {
'X-Internal-Key': this.configService.get('INTERNAL_API_KEY'),
},
},
);
if (data.tenantId) {
this.setCache(cacheKey, data.tenantId);
return data.tenantId;
}
} catch (error) {
this.logger.warn(`Failed to resolve tenant for phoneNumberId ${phoneNumberId}`);
}
return null;
}
/**
* Invalida el cache para un tenant
*/
invalidateCache(tenantId: string): void {
this.cache.delete(`whatsapp:${tenantId}`);
this.cache.delete(`llm:${tenantId}`);
}
private getFromCache(key: string): any | null {
const entry = this.cache.get(key);
if (entry && entry.expiresAt > Date.now()) {
return entry.data;
}
this.cache.delete(key);
return null;
}
private setCache(key: string, data: any): void {
this.cache.set(key, {
data,
expiresAt: Date.now() + this.CACHE_TTL,
});
}
private getDefaultBaseUrl(provider: string): string {
const defaults: Record<string, string> = {
openai: 'https://api.openai.com/v1',
openrouter: 'https://openrouter.ai/api/v1',
anthropic: 'https://api.anthropic.com/v1',
ollama: 'http://localhost:11434/v1',
azure_openai: '', // Requires custom base URL
};
return defaults[provider] || 'https://api.openai.com/v1';
}
}

8
src/llm/llm.module.ts Normal file
View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { LlmService } from './llm.service';
@Module({
providers: [LlmService],
exports: [LlmService],
})
export class LlmModule {}

312
src/llm/llm.service.ts Normal file
View File

@ -0,0 +1,312 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import { CredentialsProviderService, LLMConfig } from '../common/credentials-provider.service';
interface ConversationContext {
customerId?: string;
customerName: string;
phoneNumber: string;
lastActivity: Date;
intent?: string;
pendingAction?: string;
cart?: Array<{ productId: string; name: string; quantity: number; price: number }>;
tenantId?: string;
businessName?: string;
}
interface LlmResponse {
message: string;
action?: string;
data?: any;
intent?: string;
confidence?: number;
}
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
@Injectable()
export class LlmService {
private readonly logger = new Logger(LlmService.name);
private readonly clientCache = new Map<string, AxiosInstance>();
private readonly conversationHistory = new Map<string, ChatMessage[]>();
constructor(
private readonly configService: ConfigService,
private readonly credentialsProvider: CredentialsProviderService,
) {}
/**
* Obtiene o crea un cliente axios para la configuración LLM dada
*/
private getClient(config: LLMConfig): AxiosInstance {
const cacheKey = config.isFromPlatform
? 'platform'
: `tenant:${config.tenantId}`;
if (!this.clientCache.has(cacheKey)) {
const client = axios.create({
baseURL: config.baseUrl,
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
});
this.clientCache.set(cacheKey, client);
}
return this.clientCache.get(cacheKey);
}
/**
* Invalida el cache de cliente para un tenant
*/
invalidateClientCache(tenantId?: string): void {
const cacheKey = tenantId ? `tenant:${tenantId}` : 'platform';
this.clientCache.delete(cacheKey);
if (tenantId) {
this.credentialsProvider.invalidateCache(tenantId);
}
}
async processMessage(
userMessage: string,
context: ConversationContext,
): Promise<LlmResponse> {
try {
// Get LLM config for tenant (or platform default)
const llmConfig = await this.credentialsProvider.getLLMConfig(context.tenantId);
// Get or initialize conversation history
let history = this.conversationHistory.get(context.phoneNumber) || [];
// Initialize with system prompt if new conversation
if (history.length === 0) {
const systemPrompt = llmConfig.systemPrompt || this.getSystemPrompt(context);
history = [{ role: 'system', content: systemPrompt }];
}
// Add user message
history.push({ role: 'user', content: userMessage });
// Keep history manageable (last 20 messages + system)
if (history.length > 21) {
history = [history[0], ...history.slice(-20)];
}
// Call LLM with tenant config
const response = await this.callLlm(history, llmConfig);
// Add assistant response to history
history.push({ role: 'assistant', content: response.message });
this.conversationHistory.set(context.phoneNumber, history);
return response;
} catch (error) {
this.logger.error(`LLM error: ${error.message}`);
return this.getFallbackResponse(userMessage);
}
}
private async callLlm(messages: ChatMessage[], config: LLMConfig): Promise<LlmResponse> {
const functions = this.getAvailableFunctions();
const client = this.getClient(config);
const requestBody: any = {
model: config.model,
messages,
temperature: config.temperature,
max_tokens: config.maxTokens,
};
// Add function calling if available (OpenAI compatible)
if (functions.length > 0 && config.provider !== 'anthropic') {
requestBody.tools = functions.map((fn) => ({
type: 'function',
function: fn,
}));
requestBody.tool_choice = 'auto';
}
const source = config.isFromPlatform ? 'platform' : `tenant:${config.tenantId}`;
this.logger.debug(`Calling LLM via ${source}, model: ${config.model}`);
const { data } = await client.post('/chat/completions', requestBody);
const choice = data.choices[0];
const assistantMessage = choice.message;
// Check if LLM wants to call a function
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
const toolCall = assistantMessage.tool_calls[0];
const functionName = toolCall.function.name;
const functionArgs = JSON.parse(toolCall.function.arguments || '{}');
return {
message: assistantMessage.content || this.getActionMessage(functionName),
action: functionName,
data: functionArgs,
intent: functionName,
confidence: 0.9,
};
}
return {
message: assistantMessage.content || 'Lo siento, no pude procesar tu mensaje.',
intent: 'general_response',
confidence: 0.7,
};
}
private getSystemPrompt(context: ConversationContext): string {
const businessName = context.businessName || 'MiChangarrito';
const businessDescription = context.businessName
? `un negocio local`
: `una tiendita de barrio en Mexico`;
return `Eres el asistente virtual de ${businessName}, ${businessDescription}.
Tu nombre es "Asistente de ${businessName}" y ayudas a los clientes con:
- Informacion sobre productos y precios
- Hacer pedidos
- Consultar su cuenta de fiado (credito)
- Estado de sus pedidos
Informacion del cliente:
- Nombre: ${context.customerName}
- Tiene carrito con ${context.cart?.length || 0} productos
Reglas importantes:
1. Responde siempre en espanol mexicano, de forma amigable y breve
2. Usa emojis ocasionalmente para ser mas amigable
3. Si el cliente quiere hacer algo especifico, usa las funciones disponibles
4. Si no entiendes algo, pide aclaracion de forma amable
5. Nunca inventes precios o productos, di que consultaras el catalogo
6. Para fiados, siempre verifica primero el saldo disponible
7. Se proactivo sugiriendo opciones relevantes
Ejemplos de respuestas:
- "Claro! Te muestro el menu de productos"
- "Perfecto, agrego eso a tu carrito"
- "Dejame revisar tu cuenta de fiado..."`;
}
private getAvailableFunctions(): any[] {
return [
{
name: 'show_menu',
description: 'Muestra el menu principal de opciones al cliente',
parameters: { type: 'object', properties: {} },
},
{
name: 'show_products',
description: 'Muestra el catalogo de productos o una categoria especifica',
parameters: {
type: 'object',
properties: {
category: {
type: 'string',
description: 'Categoria de productos (bebidas, botanas, abarrotes, lacteos)',
},
},
},
},
{
name: 'show_fiado',
description: 'Muestra informacion de la cuenta de fiado del cliente',
parameters: { type: 'object', properties: {} },
},
{
name: 'add_to_cart',
description: 'Agrega un producto al carrito del cliente',
parameters: {
type: 'object',
properties: {
product_name: {
type: 'string',
description: 'Nombre del producto a agregar',
},
quantity: {
type: 'number',
description: 'Cantidad a agregar',
default: 1,
},
},
required: ['product_name'],
},
},
{
name: 'check_order_status',
description: 'Consulta el estado de los pedidos del cliente',
parameters: { type: 'object', properties: {} },
},
];
}
private getActionMessage(action: string): string {
const messages: Record<string, string> = {
show_menu: 'Te muestro el menu principal...',
show_products: 'Aqui tienes nuestros productos...',
show_fiado: 'Voy a revisar tu cuenta de fiado...',
add_to_cart: 'Agregando a tu carrito...',
check_order_status: 'Consultando tus pedidos...',
};
return messages[action] || 'Procesando tu solicitud...';
}
private getFallbackResponse(userMessage: string): LlmResponse {
const lowerMessage = userMessage.toLowerCase();
// Simple keyword matching as fallback
if (lowerMessage.includes('producto') || lowerMessage.includes('comprar')) {
return {
message: 'Te muestro nuestros productos disponibles.',
action: 'show_products',
intent: 'view_products',
confidence: 0.6,
};
}
if (lowerMessage.includes('fiado') || lowerMessage.includes('credito') || lowerMessage.includes('deuda')) {
return {
message: 'Voy a revisar tu cuenta de fiado.',
action: 'show_fiado',
intent: 'check_fiado',
confidence: 0.6,
};
}
if (lowerMessage.includes('pedido') || lowerMessage.includes('orden')) {
return {
message: 'Consultando el estado de tus pedidos.',
action: 'check_order_status',
intent: 'check_orders',
confidence: 0.6,
};
}
return {
message: 'Disculpa, no entendi bien. Puedes escribir "menu" para ver las opciones disponibles, o decirme que necesitas.',
intent: 'unknown',
confidence: 0.3,
};
}
// Clean up old conversations (call periodically)
cleanupOldConversations(maxAgeMinutes: number = 30): void {
const now = Date.now();
const maxAge = maxAgeMinutes * 60 * 1000;
for (const [phone, history] of this.conversationHistory.entries()) {
// Simple cleanup - remove if no activity
if (history.length > 0) {
// Could add timestamp tracking for more accurate cleanup
if (this.conversationHistory.size > 1000) {
this.conversationHistory.delete(phone);
}
}
}
}
}

56
src/main.ts Normal file
View File

@ -0,0 +1,56 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// Security
app.use(helmet());
// CORS
app.enableCors({
origin: configService.get('CORS_ORIGIN', '*'),
credentials: true,
});
// Validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
// Swagger
const config = new DocumentBuilder()
.setTitle('MiChangarrito WhatsApp Service')
.setDescription('Servicio de integración WhatsApp Business API')
.setVersion('1.0')
.addTag('webhook', 'WhatsApp Webhook endpoints')
.addTag('messages', 'Envío de mensajes')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
const port = configService.get('PORT', 3143);
await app.listen(port);
console.log(`
MICHANGARRITO WHATSAPP SERVICE
Status: Running
Port: ${port}
Webhook: /webhook/whatsapp
Docs: http://localhost:${port}/docs ║
`);
}
bootstrap();

View File

@ -0,0 +1,142 @@
import { IsString, IsArray, ValidateNested, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
class WebhookMessageText {
@ApiProperty()
@IsString()
body: string;
}
class WebhookMessageInteractive {
@IsString()
type: string;
@IsOptional()
button_reply?: { id: string; title: string };
@IsOptional()
list_reply?: { id: string; title: string; description?: string };
}
class WebhookMessage {
@IsString()
from: string;
@IsString()
id: string;
@IsString()
timestamp: string;
@IsString()
type: string;
@IsOptional()
@ValidateNested()
@Type(() => WebhookMessageText)
text?: WebhookMessageText;
@IsOptional()
interactive?: WebhookMessageInteractive;
@IsOptional()
image?: { id: string; mime_type: string; caption?: string };
@IsOptional()
audio?: { id: string; mime_type: string };
@IsOptional()
document?: { id: string; mime_type: string; filename?: string };
@IsOptional()
location?: { latitude: number; longitude: number; name?: string; address?: string };
}
class WebhookContact {
profile: { name: string };
wa_id: string;
}
class WebhookStatus {
@IsString()
id: string;
@IsString()
status: string;
@IsString()
timestamp: string;
@IsString()
recipient_id: string;
@IsOptional()
errors?: Array<{ code: number; title: string; message: string }>;
}
class WebhookMetadata {
@IsString()
display_phone_number: string;
@IsString()
phone_number_id: string;
}
class WebhookValue {
@IsString()
messaging_product: string;
@ValidateNested()
@Type(() => WebhookMetadata)
metadata: WebhookMetadata;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WebhookContact)
contacts?: WebhookContact[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WebhookMessage)
messages?: WebhookMessage[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WebhookStatus)
statuses?: WebhookStatus[];
}
class WebhookChange {
@ValidateNested()
@Type(() => WebhookValue)
value: WebhookValue;
@IsString()
field: string;
}
class WebhookEntry {
@IsString()
id: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => WebhookChange)
changes: WebhookChange[];
}
export class WebhookPayloadDto {
@ApiProperty({ description: 'Always "whatsapp_business_account"' })
@IsString()
object: string;
@ApiProperty({ description: 'Array of webhook entries' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => WebhookEntry)
entry: WebhookEntry[];
}

View File

@ -0,0 +1,121 @@
import {
Controller,
Get,
Post,
Body,
Query,
HttpCode,
HttpStatus,
Logger,
RawBodyRequest,
Req,
ForbiddenException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { Request } from 'express';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
import { WebhookService } from './webhook.service';
import { WebhookPayloadDto } from './dto/webhook.dto';
@ApiTags('webhook')
@Controller('webhook')
export class WebhookController {
private readonly logger = new Logger(WebhookController.name);
constructor(
private readonly webhookService: WebhookService,
private readonly configService: ConfigService,
) {}
@Get('whatsapp')
@ApiOperation({ summary: 'WhatsApp webhook verification' })
@ApiQuery({ name: 'hub.mode', required: true })
@ApiQuery({ name: 'hub.verify_token', required: true })
@ApiQuery({ name: 'hub.challenge', required: true })
@ApiResponse({ status: 200, description: 'Challenge returned' })
@ApiResponse({ status: 403, description: 'Verification failed' })
verifyWebhook(
@Query('hub.mode') mode: string,
@Query('hub.verify_token') token: string,
@Query('hub.challenge') challenge: string,
): string {
const result = this.webhookService.verifyWebhook(mode, token, challenge);
if (result === null) {
throw new ForbiddenException('Webhook verification failed');
}
return result;
}
@Post('whatsapp')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Receive WhatsApp webhook events' })
@ApiResponse({ status: 200, description: 'Event processed' })
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Body() payload: WebhookPayloadDto,
): Promise<string> {
// Verify signature in production
const appSecret = this.configService.get('WHATSAPP_APP_SECRET');
if (appSecret && req.rawBody) {
const signature = req.headers['x-hub-signature-256'] as string;
if (!this.verifySignature(req.rawBody, signature, appSecret)) {
this.logger.warn('Invalid webhook signature');
throw new ForbiddenException('Invalid signature');
}
}
this.logger.debug(`Webhook received: ${JSON.stringify(payload)}`);
// Process entries
for (const entry of payload.entry) {
for (const change of entry.changes) {
if (change.field !== 'messages') continue;
const value = change.value;
// Extract phoneNumberId from metadata to identify which WhatsApp number received the message
const phoneNumberId = value.metadata?.phone_number_id;
// Process incoming messages
if (value.messages && value.contacts) {
for (let i = 0; i < value.messages.length; i++) {
const message = value.messages[i];
const contact = value.contacts[i] || value.contacts[0];
// Process asynchronously to respond quickly to webhook
this.webhookService
.processIncomingMessage(message as any, contact as any, phoneNumberId)
.catch((err) => this.logger.error(`Error processing message: ${err.message}`));
}
}
// Process status updates
if (value.statuses) {
for (const status of value.statuses) {
this.webhookService.processStatusUpdate(status as any);
}
}
}
}
return 'OK';
}
private verifySignature(
rawBody: Buffer,
signature: string,
appSecret: string,
): boolean {
if (!signature) return false;
const expectedSignature = crypto
.createHmac('sha256', appSecret)
.update(rawBody)
.digest('hex');
return signature === `sha256=${expectedSignature}`;
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WebhookController } from './webhook.controller';
import { WebhookService } from './webhook.service';
import { WhatsAppModule } from '../whatsapp/whatsapp.module';
import { LlmModule } from '../llm/llm.module';
@Module({
imports: [WhatsAppModule, LlmModule],
controllers: [WebhookController],
providers: [WebhookService],
})
export class WebhookModule {}

View File

@ -0,0 +1,560 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { WhatsAppService } from '../whatsapp/whatsapp.service';
import { LlmService } from '../llm/llm.service';
import { CredentialsProviderService } from '../common/credentials-provider.service';
import {
WebhookIncomingMessage,
WebhookContact,
WebhookStatus,
} from '../whatsapp/interfaces/whatsapp.interface';
interface ConversationContext {
customerId?: string;
customerName: string;
phoneNumber: string;
lastActivity: Date;
intent?: string;
pendingAction?: string;
cart?: Array<{ productId: string; name: string; quantity: number; price: number }>;
tenantId?: string;
businessName?: string;
}
@Injectable()
export class WebhookService {
private readonly logger = new Logger(WebhookService.name);
private readonly conversations = new Map<string, ConversationContext>();
constructor(
private readonly configService: ConfigService,
private readonly whatsAppService: WhatsAppService,
private readonly llmService: LlmService,
private readonly credentialsProvider: CredentialsProviderService,
) {}
// ==================== WEBHOOK VERIFICATION ====================
verifyWebhook(mode: string, token: string, challenge: string): string | null {
const verifyToken = this.configService.get('WHATSAPP_VERIFY_TOKEN');
if (mode === 'subscribe' && token === verifyToken) {
this.logger.log('Webhook verified successfully');
return challenge;
}
this.logger.warn('Webhook verification failed');
return null;
}
// ==================== MESSAGE PROCESSING ====================
async processIncomingMessage(
message: WebhookIncomingMessage,
contact: WebhookContact,
phoneNumberId: string,
): Promise<void> {
const phoneNumber = message.from;
const customerName = contact.profile.name;
// Resolve tenant from phoneNumberId
const tenantId = await this.credentialsProvider.resolveTenantFromPhoneNumberId(phoneNumberId);
const source = tenantId ? `tenant:${tenantId}` : 'platform';
this.logger.log(`Processing message from ${customerName} (${phoneNumber}) via ${source}: ${message.type}`);
// Get or create conversation context
let context = this.conversations.get(phoneNumber);
if (!context) {
context = {
customerName,
phoneNumber,
lastActivity: new Date(),
cart: [],
tenantId,
// TODO: Fetch business name from tenant if available
businessName: tenantId ? undefined : 'MiChangarrito',
};
this.conversations.set(phoneNumber, context);
}
context.lastActivity = new Date();
// Update tenantId in case it changed (shouldn't normally happen)
context.tenantId = tenantId;
try {
switch (message.type) {
case 'text':
await this.handleTextMessage(phoneNumber, message.text?.body || '', context);
break;
case 'interactive':
await this.handleInteractiveMessage(phoneNumber, message, context);
break;
case 'image':
await this.handleImageMessage(phoneNumber, message, context);
break;
case 'audio':
await this.handleAudioMessage(phoneNumber, message, context);
break;
case 'location':
await this.handleLocationMessage(phoneNumber, message, context);
break;
default:
await this.whatsAppService.sendTextMessage(
phoneNumber,
'Lo siento, no puedo procesar ese tipo de mensaje. Puedes escribirme texto o usar los botones.',
context.tenantId,
);
}
} catch (error) {
this.logger.error(`Error processing message: ${error.message}`);
await this.whatsAppService.sendTextMessage(
phoneNumber,
'Disculpa, hubo un problema procesando tu mensaje. Por favor intenta de nuevo.',
context.tenantId,
);
}
}
// ==================== MESSAGE HANDLERS ====================
private async handleTextMessage(
phoneNumber: string,
text: string,
context: ConversationContext,
): Promise<void> {
const lowerText = text.toLowerCase().trim();
// Check for common commands
if (this.isGreeting(lowerText)) {
await this.sendWelcomeMessage(phoneNumber, context.customerName, context);
return;
}
if (lowerText.includes('menu') || lowerText.includes('productos')) {
await this.sendMainMenu(phoneNumber, context);
return;
}
if (lowerText.includes('ayuda') || lowerText.includes('help')) {
await this.sendHelpMessage(phoneNumber, context);
return;
}
if (lowerText.includes('pedido') || lowerText.includes('orden')) {
await this.sendOrderStatus(phoneNumber, context);
return;
}
if (lowerText.includes('fiado') || lowerText.includes('cuenta') || lowerText.includes('deuda')) {
await this.sendFiadoInfo(phoneNumber, context);
return;
}
// Use LLM for natural language processing
const response = await this.llmService.processMessage(text, context);
if (response.action) {
await this.executeAction(phoneNumber, response.action, response.data, context);
} else {
await this.whatsAppService.sendTextMessage(phoneNumber, response.message, context.tenantId);
}
}
private async handleInteractiveMessage(
phoneNumber: string,
message: WebhookIncomingMessage,
context: ConversationContext,
): Promise<void> {
const interactive = message.interactive;
if (interactive?.button_reply) {
const buttonId = interactive.button_reply.id;
await this.handleButtonResponse(phoneNumber, buttonId, context);
} else if (interactive?.list_reply) {
const listId = interactive.list_reply.id;
await this.handleListResponse(phoneNumber, listId, context);
}
}
private async handleImageMessage(
phoneNumber: string,
message: WebhookIncomingMessage,
context: ConversationContext,
): Promise<void> {
// Could be used for receipt scanning, product lookup, etc.
await this.whatsAppService.sendTextMessage(
phoneNumber,
'Gracias por la imagen. Esta funcionalidad estara disponible pronto.',
context.tenantId,
);
}
private async handleAudioMessage(
phoneNumber: string,
message: WebhookIncomingMessage,
context: ConversationContext,
): Promise<void> {
// Could be used for voice-to-text orders
await this.whatsAppService.sendTextMessage(
phoneNumber,
'Gracias por el audio. La transcripcion estara disponible pronto.',
context.tenantId,
);
}
private async handleLocationMessage(
phoneNumber: string,
message: WebhookIncomingMessage,
context: ConversationContext,
): Promise<void> {
const location = message.location;
if (location) {
// Could be used for delivery location
await this.whatsAppService.sendTextMessage(
phoneNumber,
`Ubicacion recibida. Coordenadas: ${location.latitude}, ${location.longitude}`,
context.tenantId,
);
}
}
// ==================== BUTTON/LIST HANDLERS ====================
private async handleButtonResponse(
phoneNumber: string,
buttonId: string,
context: ConversationContext,
): Promise<void> {
this.logger.log(`Button response: ${buttonId}`);
switch (buttonId) {
case 'menu_products':
await this.sendProductCategories(phoneNumber, context);
break;
case 'menu_orders':
await this.sendOrderStatus(phoneNumber, context);
break;
case 'menu_fiado':
await this.sendFiadoInfo(phoneNumber, context);
break;
case 'pay_fiado':
await this.sendPaymentOptions(phoneNumber, context);
break;
case 'check_balance':
await this.sendFiadoBalance(phoneNumber, context);
break;
case 'confirm_order':
await this.confirmOrder(phoneNumber, context);
break;
case 'cancel_order':
await this.cancelOrder(phoneNumber, context);
break;
default:
await this.whatsAppService.sendTextMessage(
phoneNumber,
'Opcion no reconocida. Escribe "menu" para ver las opciones disponibles.',
context.tenantId,
);
}
}
private async handleListResponse(
phoneNumber: string,
listId: string,
context: ConversationContext,
): Promise<void> {
this.logger.log(`List response: ${listId}`);
// Handle product selection, category selection, etc.
if (listId.startsWith('cat_')) {
const categoryId = listId.replace('cat_', '');
await this.sendCategoryProducts(phoneNumber, categoryId, context);
} else if (listId.startsWith('prod_')) {
const productId = listId.replace('prod_', '');
await this.addToCart(phoneNumber, productId, context);
}
}
// ==================== BUSINESS ACTIONS ====================
private async sendWelcomeMessage(
phoneNumber: string,
customerName: string,
context: ConversationContext,
): Promise<void> {
const businessName = context.businessName || 'MiChangarrito';
const message = `Hola ${customerName}! Bienvenido a ${businessName}.
Soy tu asistente virtual. Puedo ayudarte con:
- Ver productos disponibles
- Hacer pedidos
- Consultar tu cuenta de fiado
- Revisar el estado de tus pedidos
Como puedo ayudarte hoy?`;
await this.whatsAppService.sendInteractiveButtons(
phoneNumber,
message,
[
{ id: 'menu_products', title: 'Ver productos' },
{ id: 'menu_orders', title: 'Mis pedidos' },
{ id: 'menu_fiado', title: 'Mi fiado' },
],
undefined,
undefined,
context.tenantId,
);
}
private async sendMainMenu(phoneNumber: string, context: ConversationContext): Promise<void> {
await this.whatsAppService.sendInteractiveButtons(
phoneNumber,
'Que te gustaria hacer?',
[
{ id: 'menu_products', title: 'Ver productos' },
{ id: 'menu_orders', title: 'Mis pedidos' },
{ id: 'menu_fiado', title: 'Mi fiado' },
],
'Menu Principal',
undefined,
context.tenantId,
);
}
private async sendHelpMessage(phoneNumber: string, context: ConversationContext): Promise<void> {
const helpText = `*Comandos disponibles:*
- *menu* - Ver opciones principales
- *productos* - Ver catalogo
- *pedido* - Estado de tu pedido
- *fiado* - Tu cuenta de fiado
- *ayuda* - Este mensaje
Tambien puedes escribirme de forma natural y tratare de entenderte!`;
await this.whatsAppService.sendTextMessage(phoneNumber, helpText, context.tenantId);
}
private async sendProductCategories(phoneNumber: string, context: ConversationContext): Promise<void> {
// TODO: Fetch from backend API using context.tenantId
const categories = [
{ id: 'cat_bebidas', title: 'Bebidas', description: 'Refrescos, aguas, jugos' },
{ id: 'cat_botanas', title: 'Botanas', description: 'Papas, cacahuates, dulces' },
{ id: 'cat_abarrotes', title: 'Abarrotes', description: 'Productos basicos' },
{ id: 'cat_lacteos', title: 'Lacteos', description: 'Leche, queso, yogurt' },
];
await this.whatsAppService.sendInteractiveList(
phoneNumber,
'Selecciona una categoria para ver los productos disponibles:',
'Ver categorias',
[{ title: 'Categorias', rows: categories }],
'Catalogo',
undefined,
context.tenantId,
);
}
private async sendCategoryProducts(
phoneNumber: string,
categoryId: string,
context: ConversationContext,
): Promise<void> {
// TODO: Fetch from backend API based on categoryId and context.tenantId
const products = [
{ id: 'prod_1', title: 'Coca-Cola 600ml', description: '$18.00' },
{ id: 'prod_2', title: 'Pepsi 600ml', description: '$17.00' },
{ id: 'prod_3', title: 'Agua natural 1L', description: '$12.00' },
];
await this.whatsAppService.sendInteractiveList(
phoneNumber,
'Selecciona un producto para agregarlo a tu carrito:',
'Ver productos',
[{ title: 'Productos', rows: products }],
'Productos disponibles',
undefined,
context.tenantId,
);
}
private async addToCart(
phoneNumber: string,
productId: string,
context: ConversationContext,
): Promise<void> {
// TODO: Fetch product from backend and add to cart using context.tenantId
if (!context.cart) {
context.cart = [];
}
// Mock product
context.cart.push({
productId,
name: 'Producto de prueba',
quantity: 1,
price: 18.00,
});
const cartTotal = context.cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
await this.whatsAppService.sendInteractiveButtons(
phoneNumber,
`Producto agregado al carrito.\n\nTotal actual: $${cartTotal.toFixed(2)}\nArticulos: ${context.cart.length}`,
[
{ id: 'menu_products', title: 'Seguir comprando' },
{ id: 'confirm_order', title: 'Finalizar pedido' },
],
undefined,
undefined,
context.tenantId,
);
}
private async sendOrderStatus(
phoneNumber: string,
context: ConversationContext,
): Promise<void> {
// TODO: Fetch from backend API using context.tenantId
await this.whatsAppService.sendTextMessage(
phoneNumber,
'No tienes pedidos activos en este momento.\n\nEscribe "menu" para hacer un nuevo pedido.',
context.tenantId,
);
}
private async sendFiadoInfo(
phoneNumber: string,
context: ConversationContext,
): Promise<void> {
// TODO: Fetch from backend API using context.tenantId
await this.whatsAppService.sendInteractiveButtons(
phoneNumber,
'Tu cuenta de fiado:\n\nSaldo pendiente: *$0.00*\nLimite de credito: *$500.00*\nCredito disponible: *$500.00*',
[
{ id: 'pay_fiado', title: 'Hacer pago' },
{ id: 'menu_products', title: 'Comprar a fiado' },
],
'Mi Fiado',
undefined,
context.tenantId,
);
}
private async sendFiadoBalance(
phoneNumber: string,
context: ConversationContext,
): Promise<void> {
// TODO: Fetch detailed balance from backend using context.tenantId
await this.whatsAppService.sendTextMessage(
phoneNumber,
'*Detalle de tu cuenta:*\n\nNo hay movimientos pendientes.',
context.tenantId,
);
}
private async sendPaymentOptions(
phoneNumber: string,
context: ConversationContext,
): Promise<void> {
await this.whatsAppService.sendTextMessage(
phoneNumber,
'*Opciones de pago:*\n\n1. Efectivo en tienda\n2. Transferencia bancaria\n\nPara transferencias:\nCLABE: XXXX XXXX XXXX XXXX\nBanco: BBVA\n\nEnvia tu comprobante por este medio.',
context.tenantId,
);
}
private async confirmOrder(
phoneNumber: string,
context: ConversationContext,
): Promise<void> {
if (!context.cart || context.cart.length === 0) {
await this.whatsAppService.sendTextMessage(
phoneNumber,
'Tu carrito esta vacio. Escribe "productos" para ver el catalogo.',
context.tenantId,
);
return;
}
const total = context.cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
// TODO: Create order in backend using context.tenantId
const orderNumber = `MCH-${Date.now().toString(36).toUpperCase()}`;
await this.whatsAppService.sendOrderConfirmation(
phoneNumber,
orderNumber,
context.cart,
total,
context.tenantId,
);
// Clear cart
context.cart = [];
}
private async cancelOrder(
phoneNumber: string,
context: ConversationContext,
): Promise<void> {
context.cart = [];
await this.whatsAppService.sendTextMessage(
phoneNumber,
'Pedido cancelado. Tu carrito ha sido vaciado.\n\nEscribe "menu" cuando quieras hacer un nuevo pedido.',
context.tenantId,
);
}
private async executeAction(
phoneNumber: string,
action: string,
data: any,
context: ConversationContext,
): Promise<void> {
// Execute LLM-determined actions
switch (action) {
case 'show_menu':
await this.sendMainMenu(phoneNumber, context);
break;
case 'show_products':
await this.sendProductCategories(phoneNumber, context);
break;
case 'show_fiado':
await this.sendFiadoInfo(phoneNumber, context);
break;
default:
this.logger.warn(`Unknown action: ${action}`);
}
}
// ==================== STATUS UPDATES ====================
processStatusUpdate(status: WebhookStatus): void {
this.logger.log(`Message ${status.id} status: ${status.status}`);
if (status.status === 'failed' && status.errors) {
this.logger.error(`Message failed: ${JSON.stringify(status.errors)}`);
}
}
// ==================== HELPERS ====================
private isGreeting(text: string): boolean {
const greetings = ['hola', 'hi', 'hello', 'buenos dias', 'buenas tardes', 'buenas noches', 'que tal', 'hey'];
return greetings.some((g) => text.includes(g));
}
}

View File

@ -0,0 +1,170 @@
// WhatsApp Cloud API Types
export interface WhatsAppMessage {
messaging_product: 'whatsapp';
recipient_type: 'individual';
to: string;
type: MessageType;
text?: TextMessage;
template?: TemplateMessage;
interactive?: InteractiveMessage;
image?: MediaMessage;
document?: MediaMessage;
audio?: MediaMessage;
}
export type MessageType = 'text' | 'template' | 'interactive' | 'image' | 'document' | 'audio';
export interface TextMessage {
preview_url?: boolean;
body: string;
}
export interface TemplateMessage {
name: string;
language: {
code: string;
};
components?: TemplateComponent[];
}
export interface TemplateComponent {
type: 'header' | 'body' | 'button';
parameters?: TemplateParameter[];
sub_type?: string;
index?: number;
}
export interface TemplateParameter {
type: 'text' | 'image' | 'document' | 'video';
text?: string;
image?: { link: string };
}
export interface InteractiveMessage {
type: 'button' | 'list' | 'product' | 'product_list';
header?: {
type: 'text' | 'image' | 'document' | 'video';
text?: string;
};
body: {
text: string;
};
footer?: {
text: string;
};
action: InteractiveAction;
}
export interface InteractiveAction {
buttons?: InteractiveButton[];
button?: string;
sections?: InteractiveSection[];
}
export interface InteractiveButton {
type: 'reply';
reply: {
id: string;
title: string;
};
}
export interface InteractiveSection {
title?: string;
rows: InteractiveSectionRow[];
}
export interface InteractiveSectionRow {
id: string;
title: string;
description?: string;
}
export interface MediaMessage {
link?: string;
id?: string;
caption?: string;
filename?: string;
}
// Webhook Types
export interface WebhookPayload {
object: string;
entry: WebhookEntry[];
}
export interface WebhookEntry {
id: string;
changes: WebhookChange[];
}
export interface WebhookChange {
value: WebhookValue;
field: string;
}
export interface WebhookValue {
messaging_product: string;
metadata: {
display_phone_number: string;
phone_number_id: string;
};
contacts?: WebhookContact[];
messages?: WebhookIncomingMessage[];
statuses?: WebhookStatus[];
}
export interface WebhookContact {
profile: {
name: string;
};
wa_id: string;
}
export interface WebhookIncomingMessage {
from: string;
id: string;
timestamp: string;
type: string;
text?: { body: string };
image?: WebhookMedia;
audio?: WebhookMedia;
document?: WebhookMedia;
location?: {
latitude: number;
longitude: number;
name?: string;
address?: string;
};
interactive?: {
type: string;
button_reply?: { id: string; title: string };
list_reply?: { id: string; title: string; description?: string };
};
context?: {
from: string;
id: string;
};
}
export interface WebhookMedia {
id: string;
mime_type: string;
sha256?: string;
caption?: string;
filename?: string;
}
export interface WebhookStatus {
id: string;
status: 'sent' | 'delivered' | 'read' | 'failed';
timestamp: string;
recipient_id: string;
errors?: Array<{
code: number;
title: string;
message: string;
}>;
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { WhatsAppService } from './whatsapp.service';
@Module({
providers: [WhatsAppService],
exports: [WhatsAppService],
})
export class WhatsAppModule {}

View File

@ -0,0 +1,356 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import {
WhatsAppMessage,
TextMessage,
InteractiveMessage,
TemplateMessage,
} from './interfaces/whatsapp.interface';
import {
CredentialsProviderService,
WhatsAppCredentials,
} from '../common/credentials-provider.service';
@Injectable()
export class WhatsAppService {
private readonly logger = new Logger(WhatsAppService.name);
private readonly clientCache = new Map<string, AxiosInstance>();
constructor(
private readonly configService: ConfigService,
private readonly credentialsProvider: CredentialsProviderService,
) {}
/**
* Obtiene o crea un cliente axios para las credenciales dadas
*/
private getClient(credentials: WhatsAppCredentials): AxiosInstance {
const cacheKey = credentials.isFromPlatform
? 'platform'
: `tenant:${credentials.tenantId}`;
if (!this.clientCache.has(cacheKey)) {
const client = axios.create({
baseURL: 'https://graph.facebook.com/v18.0',
headers: {
Authorization: `Bearer ${credentials.accessToken}`,
'Content-Type': 'application/json',
},
});
this.clientCache.set(cacheKey, client);
}
return this.clientCache.get(cacheKey);
}
/**
* Obtiene el phoneNumberId para un tenant
*/
private async getPhoneNumberId(tenantId?: string): Promise<string> {
const credentials = await this.credentialsProvider.getWhatsAppCredentials(tenantId);
return credentials.phoneNumberId;
}
/**
* Invalida el cache de cliente para un tenant
*/
invalidateClientCache(tenantId?: string): void {
const cacheKey = tenantId ? `tenant:${tenantId}` : 'platform';
this.clientCache.delete(cacheKey);
if (tenantId) {
this.credentialsProvider.invalidateCache(tenantId);
}
}
// ==================== SEND MESSAGES ====================
async sendTextMessage(to: string, text: string, tenantId?: string): Promise<string> {
const message: WhatsAppMessage = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: this.formatPhoneNumber(to),
type: 'text',
text: { body: text },
};
return this.sendMessage(message, tenantId);
}
async sendInteractiveButtons(
to: string,
bodyText: string,
buttons: Array<{ id: string; title: string }>,
headerText?: string,
footerText?: string,
tenantId?: string,
): Promise<string> {
const message: WhatsAppMessage = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: this.formatPhoneNumber(to),
type: 'interactive',
interactive: {
type: 'button',
header: headerText ? { type: 'text', text: headerText } : undefined,
body: { text: bodyText },
footer: footerText ? { text: footerText } : undefined,
action: {
buttons: buttons.slice(0, 3).map((btn) => ({
type: 'reply' as const,
reply: { id: btn.id, title: btn.title.substring(0, 20) },
})),
},
},
};
return this.sendMessage(message, tenantId);
}
async sendInteractiveList(
to: string,
bodyText: string,
buttonText: string,
sections: Array<{
title?: string;
rows: Array<{ id: string; title: string; description?: string }>;
}>,
headerText?: string,
footerText?: string,
tenantId?: string,
): Promise<string> {
const message: WhatsAppMessage = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: this.formatPhoneNumber(to),
type: 'interactive',
interactive: {
type: 'list',
header: headerText ? { type: 'text', text: headerText } : undefined,
body: { text: bodyText },
footer: footerText ? { text: footerText } : undefined,
action: {
button: buttonText.substring(0, 20),
sections: sections.map((section) => ({
title: section.title?.substring(0, 24),
rows: section.rows.slice(0, 10).map((row) => ({
id: row.id.substring(0, 200),
title: row.title.substring(0, 24),
description: row.description?.substring(0, 72),
})),
})),
},
},
};
return this.sendMessage(message, tenantId);
}
async sendTemplate(
to: string,
templateName: string,
languageCode: string = 'es_MX',
components?: any[],
tenantId?: string,
): Promise<string> {
const message: WhatsAppMessage = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: this.formatPhoneNumber(to),
type: 'template',
template: {
name: templateName,
language: { code: languageCode },
components,
},
};
return this.sendMessage(message, tenantId);
}
async sendImage(to: string, imageUrl: string, caption?: string, tenantId?: string): Promise<string> {
const message: WhatsAppMessage = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: this.formatPhoneNumber(to),
type: 'image',
image: {
link: imageUrl,
caption,
},
};
return this.sendMessage(message, tenantId);
}
async sendDocument(
to: string,
documentUrl: string,
filename: string,
caption?: string,
tenantId?: string,
): Promise<string> {
const message: WhatsAppMessage = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: this.formatPhoneNumber(to),
type: 'document',
document: {
link: documentUrl,
filename,
caption,
},
};
return this.sendMessage(message, tenantId);
}
// ==================== MEDIA ====================
async downloadMedia(mediaId: string, tenantId?: string): Promise<Buffer> {
try {
const credentials = await this.credentialsProvider.getWhatsAppCredentials(tenantId);
const client = this.getClient(credentials);
// Get media URL
const { data: mediaInfo } = await client.get(`/${mediaId}`);
// Download media
const { data: mediaBuffer } = await axios.get(mediaInfo.url, {
headers: {
Authorization: `Bearer ${credentials.accessToken}`,
},
responseType: 'arraybuffer',
});
return Buffer.from(mediaBuffer);
} catch (error) {
this.logger.error(`Error downloading media: ${error.message}`);
throw error;
}
}
// ==================== HELPERS ====================
private async sendMessage(message: WhatsAppMessage, tenantId?: string): Promise<string> {
try {
const credentials = await this.credentialsProvider.getWhatsAppCredentials(tenantId);
const client = this.getClient(credentials);
const { data } = await client.post(
`/${credentials.phoneNumberId}/messages`,
message,
);
const source = credentials.isFromPlatform ? 'platform' : `tenant:${tenantId}`;
this.logger.log(`Message sent to ${message.to} via ${source}: ${data.messages[0].id}`);
return data.messages[0].id;
} catch (error) {
this.logger.error(`Error sending message: ${error.response?.data || error.message}`);
throw error;
}
}
private formatPhoneNumber(phone: string): string {
// Remove all non-numeric characters
let cleaned = phone.replace(/\D/g, '');
// Add Mexico country code if not present
if (cleaned.length === 10) {
cleaned = '52' + cleaned;
}
return cleaned;
}
// ==================== BUSINESS MESSAGES ====================
async sendOrderConfirmation(
to: string,
orderNumber: string,
items: Array<{ name: string; quantity: number; price: number }>,
total: number,
tenantId?: string,
): Promise<string> {
const itemsList = items
.map((item) => `${item.quantity}x ${item.name} - $${item.price.toFixed(2)}`)
.join('\n');
const message = `*Pedido Confirmado* 🎉
Número: *${orderNumber}*
${itemsList}
*Total: $${total.toFixed(2)}*
Te avisaremos cuando esté listo.`;
return this.sendTextMessage(to, message, tenantId);
}
async sendOrderStatusUpdate(
to: string,
orderNumber: string,
status: 'preparing' | 'ready' | 'delivered',
tenantId?: string,
): Promise<string> {
const statusMessages = {
preparing: `*Preparando tu pedido* 👨‍🍳\n\nPedido: ${orderNumber}\n\nEstamos trabajando en tu orden.`,
ready: `*¡Pedido listo!* ✅\n\nPedido: ${orderNumber}\n\nTu pedido está listo para recoger.`,
delivered: `*Pedido entregado* 📦\n\nPedido: ${orderNumber}\n\n¡Gracias por tu compra!`,
};
return this.sendTextMessage(to, statusMessages[status], tenantId);
}
async sendFiadoReminder(
to: string,
customerName: string,
amount: number,
dueDate?: string,
tenantId?: string,
): Promise<string> {
let message = `Hola ${customerName} 👋
Te recordamos que tienes un saldo pendiente de *$${amount.toFixed(2)}*`;
if (dueDate) {
message += ` con vencimiento el ${dueDate}`;
}
message += `.\n\n¿Deseas abonar o consultar tu estado de cuenta?`;
return this.sendInteractiveButtons(
to,
message,
[
{ id: 'pay_fiado', title: 'Quiero pagar' },
{ id: 'check_balance', title: 'Ver mi cuenta' },
],
undefined,
undefined,
tenantId,
);
}
async sendLowStockAlert(
to: string,
products: Array<{ name: string; stock: number }>,
tenantId?: string,
): Promise<string> {
const productList = products
.map((p) => `${p.name}: ${p.stock} unidades`)
.join('\n');
const message = `*⚠️ Alerta de Stock Bajo*
Los siguientes productos necesitan reabastecimiento:
${productList}
¿Deseas hacer un pedido al proveedor?`;
return this.sendTextMessage(to, message, tenantId);
}
}

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true
}
}