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