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:
parent
2937c607c1
commit
7ca75455bc
33
.dockerignore
Normal file
33
.dockerignore
Normal 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
24
.env
Normal 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
19
.env.example
Normal 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
87
Dockerfile
Normal 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"]
|
||||
@ -1,3 +0,0 @@
|
||||
# michangarrito-whatsapp-service-v2
|
||||
|
||||
WhatsApp Service de michangarrito - Workspace V2
|
||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal 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
4785
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal 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
20
src/app.module.ts
Normal 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 {}
|
||||
11
src/common/common.module.ts
Normal file
11
src/common/common.module.ts
Normal 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 {}
|
||||
249
src/common/credentials-provider.service.ts
Normal file
249
src/common/credentials-provider.service.ts
Normal 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
8
src/llm/llm.module.ts
Normal 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
312
src/llm/llm.service.ts
Normal 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
56
src/main.ts
Normal 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();
|
||||
142
src/webhook/dto/webhook.dto.ts
Normal file
142
src/webhook/dto/webhook.dto.ts
Normal 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[];
|
||||
}
|
||||
121
src/webhook/webhook.controller.ts
Normal file
121
src/webhook/webhook.controller.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
12
src/webhook/webhook.module.ts
Normal file
12
src/webhook/webhook.module.ts
Normal 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 {}
|
||||
560
src/webhook/webhook.service.ts
Normal file
560
src/webhook/webhook.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
170
src/whatsapp/interfaces/whatsapp.interface.ts
Normal file
170
src/whatsapp/interfaces/whatsapp.interface.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
8
src/whatsapp/whatsapp.module.ts
Normal file
8
src/whatsapp/whatsapp.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WhatsAppService } from './whatsapp.service';
|
||||
|
||||
@Module({
|
||||
providers: [WhatsAppService],
|
||||
exports: [WhatsAppService],
|
||||
})
|
||||
export class WhatsAppModule {}
|
||||
356
src/whatsapp/whatsapp.service.ts
Normal file
356
src/whatsapp/whatsapp.service.ts
Normal 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
22
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user