From 7088ad05d26a07486ff8a7d7c8cde210d0c8ded9 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Wed, 17 Sep 2025 15:43:32 -0300 Subject: [PATCH] feat: add project guidelines and configuration files for development standards - Introduce AGENTS.md for repository guidelines and project structure - Add core development principles in .cursor/rules/core-development.mdc - Establish project-specific context in .cursor/rules/project-context.mdc - Implement Cursor IDE configuration in .cursor/rules/cursor.json - Create specialized rules for controllers, services, DTOs, guards, routes, and integrations - Update .gitignore to exclude unnecessary files - Enhance CLAUDE.md with project overview and common development commands --- .cursor/rules/README.md | 106 +++ .cursor/rules/core-development.mdc | 144 +++ .cursor/rules/cursor.json | 179 ++++ .cursor/rules/project-context.mdc | 174 ++++ .../specialized-rules/controller-rules.mdc | 342 +++++++ .cursor/rules/specialized-rules/dto-rules.mdc | 433 +++++++++ .../rules/specialized-rules/guard-rules.mdc | 416 +++++++++ .../integration-channel-rules.mdc | 552 ++++++++++++ .../integration-chatbot-rules.mdc | 597 ++++++++++++ .../integration-event-rules.mdc | 851 ++++++++++++++++++ .../integration-storage-rules.mdc | 608 +++++++++++++ .../rules/specialized-rules/route-rules.mdc | 416 +++++++++ .../rules/specialized-rules/service-rules.mdc | 294 ++++++ .../rules/specialized-rules/type-rules.mdc | 490 ++++++++++ .../rules/specialized-rules/util-rules.mdc | 653 ++++++++++++++ .../specialized-rules/validate-rules.mdc | 498 ++++++++++ .gitignore | 4 - AGENTS.md | 41 + CLAUDE.md | 204 +++-- 19 files changed, 6939 insertions(+), 63 deletions(-) create mode 100644 .cursor/rules/README.md create mode 100644 .cursor/rules/core-development.mdc create mode 100644 .cursor/rules/cursor.json create mode 100644 .cursor/rules/project-context.mdc create mode 100644 .cursor/rules/specialized-rules/controller-rules.mdc create mode 100644 .cursor/rules/specialized-rules/dto-rules.mdc create mode 100644 .cursor/rules/specialized-rules/guard-rules.mdc create mode 100644 .cursor/rules/specialized-rules/integration-channel-rules.mdc create mode 100644 .cursor/rules/specialized-rules/integration-chatbot-rules.mdc create mode 100644 .cursor/rules/specialized-rules/integration-event-rules.mdc create mode 100644 .cursor/rules/specialized-rules/integration-storage-rules.mdc create mode 100644 .cursor/rules/specialized-rules/route-rules.mdc create mode 100644 .cursor/rules/specialized-rules/service-rules.mdc create mode 100644 .cursor/rules/specialized-rules/type-rules.mdc create mode 100644 .cursor/rules/specialized-rules/util-rules.mdc create mode 100644 .cursor/rules/specialized-rules/validate-rules.mdc create mode 100644 AGENTS.md diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md new file mode 100644 index 00000000..fdc82a68 --- /dev/null +++ b/.cursor/rules/README.md @@ -0,0 +1,106 @@ +# Evolution API Cursor Rules + +Este diretório contém as regras e configurações do Cursor IDE para o projeto Evolution API. + +## Estrutura dos Arquivos + +### Arquivos Principais (alwaysApply: true) +- **`core-development.mdc`** - Princípios fundamentais de desenvolvimento +- **`project-context.mdc`** - Contexto específico do projeto Evolution API +- **`cursor.json`** - Configurações do Cursor IDE + +### Regras Especializadas (alwaysApply: false) +Estas regras são ativadas automaticamente quando você trabalha nos arquivos correspondentes: + +#### Camadas da Aplicação +- **`specialized-rules/service-rules.mdc`** - Padrões para services (`src/api/services/`) +- **`specialized-rules/controller-rules.mdc`** - Padrões para controllers (`src/api/controllers/`) +- **`specialized-rules/dto-rules.mdc`** - Padrões para DTOs (`src/api/dto/`) +- **`specialized-rules/guard-rules.mdc`** - Padrões para guards (`src/api/guards/`) +- **`specialized-rules/route-rules.mdc`** - Padrões para routers (`src/api/routes/`) + +#### Tipos e Validação +- **`specialized-rules/type-rules.mdc`** - Definições TypeScript (`src/api/types/`) +- **`specialized-rules/validate-rules.mdc`** - Schemas de validação (`src/validate/`) + +#### Utilitários +- **`specialized-rules/util-rules.mdc`** - Funções utilitárias (`src/utils/`) + +#### Integrações +- **`specialized-rules/integration-channel-rules.mdc`** - Integrações de canal (`src/api/integrations/channel/`) +- **`specialized-rules/integration-chatbot-rules.mdc`** - Integrações de chatbot (`src/api/integrations/chatbot/`) +- **`specialized-rules/integration-storage-rules.mdc`** - Integrações de storage (`src/api/integrations/storage/`) +- **`specialized-rules/integration-event-rules.mdc`** - Integrações de eventos (`src/api/integrations/event/`) + +## Como Usar + +### Referências Cruzadas +Os arquivos principais fazem referência aos especializados usando a sintaxe `@specialized-rules/nome-do-arquivo.mdc`. Quando você trabalha em um arquivo específico, o Cursor automaticamente carrega as regras relevantes. + +### Exemplo de Uso +Quando você edita um arquivo em `src/api/services/`, o Cursor automaticamente: +1. Carrega `core-development.mdc` (sempre ativo) +2. Carrega `project-context.mdc` (sempre ativo) +3. Carrega `specialized-rules/service-rules.mdc` (ativado pelo glob pattern) + +### Padrões de Código +Cada arquivo de regras contém: +- **Estruturas padrão** - Como organizar o código +- **Padrões de nomenclatura** - Convenções de nomes +- **Exemplos práticos** - Código de exemplo +- **Anti-padrões** - O que evitar +- **Testes** - Como testar o código + +## Configuração do Cursor + +O arquivo `cursor.json` contém: +- Configurações de formatação +- Padrões de código específicos do Evolution API +- Diretórios principais do projeto +- Integrações e tecnologias utilizadas + +## Manutenção + +Para manter as regras atualizadas: +1. Analise novos padrões no código +2. Atualize as regras especializadas correspondentes +3. Mantenha os exemplos sincronizados com o código real +4. Documente mudanças significativas + +## Tecnologias Cobertas + +- **Backend**: Node.js 20+ + TypeScript 5+ + Express.js +- **Database**: Prisma ORM (PostgreSQL/MySQL) +- **Cache**: Redis + Node-cache +- **Queue**: RabbitMQ + Amazon SQS +- **Real-time**: Socket.io +- **Storage**: AWS S3 + Minio +- **Validation**: class-validator + Joi +- **Logging**: Pino +- **WhatsApp**: Baileys + Meta Business API +- **Integrations**: Chatwoot, Typebot, OpenAI, Dify + +## Estrutura do Projeto + +``` +src/ +├── api/ +│ ├── controllers/ # Controllers (HTTP handlers) +│ ├── services/ # Business logic +│ ├── dto/ # Data Transfer Objects +│ ├── guards/ # Authentication/Authorization +│ ├── routes/ # Express routers +│ ├── types/ # TypeScript definitions +│ └── integrations/ # External integrations +│ ├── channel/ # WhatsApp channels (Baileys, Business API) +│ ├── chatbot/ # Chatbot integrations +│ ├── event/ # Event integrations +│ └── storage/ # Storage integrations +├── cache/ # Cache implementations +├── config/ # Configuration files +├── utils/ # Utility functions +├── validate/ # Validation schemas +└── exceptions/ # Custom exceptions +``` + +Este sistema de regras garante consistência no código e facilita o desenvolvimento seguindo os padrões estabelecidos do Evolution API. diff --git a/.cursor/rules/core-development.mdc b/.cursor/rules/core-development.mdc new file mode 100644 index 00000000..f245b99d --- /dev/null +++ b/.cursor/rules/core-development.mdc @@ -0,0 +1,144 @@ +--- +description: Core development principles and standards for Evolution API development +globs: +alwaysApply: true +--- + +# Evolution API Development Standards + +## Cross-References +- **Project Context**: @project-context.mdc for Evolution API-specific patterns +- **Specialized Rules**: + - @specialized-rules/service-rules.mdc for service layer patterns + - @specialized-rules/controller-rules.mdc for controller patterns + - @specialized-rules/dto-rules.mdc for DTO validation patterns + - @specialized-rules/guard-rules.mdc for authentication/authorization + - @specialized-rules/route-rules.mdc for router patterns + - @specialized-rules/type-rules.mdc for TypeScript definitions + - @specialized-rules/util-rules.mdc for utility functions + - @specialized-rules/validate-rules.mdc for validation schemas + - @specialized-rules/integration-channel-rules.mdc for channel integrations + - @specialized-rules/integration-chatbot-rules.mdc for chatbot integrations + - @specialized-rules/integration-storage-rules.mdc for storage integrations + - @specialized-rules/integration-event-rules.mdc for event integrations +- **TypeScript/Node.js**: Node.js 20+ + TypeScript 5+ best practices +- **Express/Prisma**: Express.js + Prisma ORM patterns +- **WhatsApp Integrations**: Baileys + Meta Business API patterns + +## Senior Engineer Context - Evolution API Platform +- You are a senior software engineer working on a WhatsApp API platform +- Focus on Node.js + TypeScript + Express.js full-stack development +- Specialized in real-time messaging, WhatsApp integrations, and event-driven architecture +- Apply scalable patterns for multi-tenant API platform +- Consider WhatsApp integration workflow implications and performance at scale + +## Fundamental Principles + +### Code Quality Standards +- **Simplicity First**: Always prefer simple solutions over complex ones +- **DRY Principle**: Avoid code duplication - check for existing similar functionality before implementing +- **Single Responsibility**: Each function/class should have one clear purpose +- **Readable Code**: Write code that tells a story - clear naming and structure + +### Problem Resolution Approach +- **Follow Existing Patterns**: Use established Service patterns, DTOs, and Integration patterns +- **Event-Driven First**: Leverage EventEmitter2 for event publishing when adding new features +- **Integration Pattern**: Follow existing WhatsApp integration patterns for new channels +- **Conservative Changes**: Prefer extending existing services over creating new architecture +- **Clean Migration**: Remove deprecated patterns when introducing new ones +- **Incremental Changes**: Break large changes into smaller, testable increments with proper migrations + +### File and Function Organization - Node.js/TypeScript Structure +- **Services**: Keep services focused and under 200 lines +- **Controllers**: Keep controllers thin - only routing and validation +- **DTOs**: Use class-validator for all input validation +- **Integrations**: Follow `src/api/integrations/` structure for new integrations +- **Utils**: Extract common functionality into well-named utilities +- **Types**: Define clear TypeScript interfaces and types + +### Code Analysis and Reflection +- After writing code, deeply reflect on scalability and maintainability +- Provide 1-2 paragraph analysis of code changes +- Suggest improvements or next steps based on reflection +- Consider performance, security, and maintenance implications + +## Development Standards + +### TypeScript Standards +- **Strict Mode**: Always use TypeScript strict mode +- **No Any**: Avoid `any` type - use proper typing +- **Interfaces**: Define clear contracts with interfaces +- **Enums**: Use enums for constants and status values +- **Generics**: Use generics for reusable components + +### Error Handling Standards +- **HTTP Exceptions**: Use appropriate HTTP status codes +- **Logging**: Structured logging with context +- **Retry Logic**: Implement retry for external services +- **Graceful Degradation**: Handle service failures gracefully + +### Security Standards +- **Input Validation**: Validate all inputs with class-validator +- **Authentication**: Use API keys and JWT tokens +- **Rate Limiting**: Implement rate limiting for APIs +- **Data Sanitization**: Sanitize sensitive data in logs + +### Performance Standards +- **Caching**: Use Redis for frequently accessed data +- **Database**: Optimize Prisma queries with proper indexing +- **Memory**: Monitor memory usage and implement cleanup +- **Async**: Use async/await properly with error handling + +## Communication Standards + +### Language Requirements +- **User Communication**: Always respond in Portuguese (PT-BR) +- **Code Comments**: English for technical documentation +- **API Documentation**: English for consistency +- **Error Messages**: Portuguese for user-facing errors + +### Documentation Standards +- **Code Comments**: Document complex business logic +- **API Documentation**: Document all public endpoints +- **README**: Keep project documentation updated +- **Changelog**: Document breaking changes + +## Quality Assurance + +### Testing Standards +- **Unit Tests**: Test business logic in services +- **Integration Tests**: Test API endpoints +- **Mocks**: Mock external dependencies +- **Coverage**: Aim for 70%+ test coverage + +### Code Review Standards +- **Peer Review**: All code must be reviewed +- **Automated Checks**: ESLint, Prettier, TypeScript +- **Security Review**: Check for security vulnerabilities +- **Performance Review**: Check for performance issues + +## Evolution API Specific Patterns + +### WhatsApp Integration Patterns +- **Connection Management**: One connection per instance +- **Event Handling**: Proper event listeners for Baileys +- **Message Processing**: Queue-based message processing +- **Error Recovery**: Automatic reconnection logic + +### Multi-Database Support +- **Schema Compatibility**: Support PostgreSQL and MySQL +- **Migration Sync**: Keep migrations synchronized +- **Type Safety**: Use Prisma generated types +- **Connection Pooling**: Proper database connection management + +### Cache Strategy +- **Redis Primary**: Use Redis for distributed caching +- **Local Fallback**: Node-cache for local fallback +- **TTL Strategy**: Appropriate TTL for different data types +- **Cache Invalidation**: Proper cache invalidation patterns + +### Event System +- **EventEmitter2**: Use for internal events +- **WebSocket**: Socket.io for real-time updates +- **Queue Systems**: RabbitMQ/SQS for async processing +- **Webhook Processing**: Proper webhook validation and processing \ No newline at end of file diff --git a/.cursor/rules/cursor.json b/.cursor/rules/cursor.json new file mode 100644 index 00000000..a3d4275f --- /dev/null +++ b/.cursor/rules/cursor.json @@ -0,0 +1,179 @@ +{ + "version": "1.0", + "description": "Cursor IDE configuration for Evolution API project", + "rules": { + "general": { + "max_line_length": 120, + "indent_size": 2, + "end_of_line": "lf", + "charset": "utf-8", + "trim_trailing_whitespace": true, + "insert_final_newline": true + }, + "typescript": { + "quotes": "single", + "semi": true, + "trailing_comma": "es5", + "bracket_spacing": true, + "arrow_parens": "avoid", + "print_width": 120, + "tab_width": 2, + "use_tabs": false, + "single_quote": true, + "end_of_line": "lf", + "strict": true, + "no_implicit_any": true, + "strict_null_checks": true + }, + "javascript": { + "quotes": "single", + "semi": true, + "trailing_comma": "es5", + "bracket_spacing": true, + "arrow_parens": "avoid", + "print_width": 120, + "tab_width": 2, + "use_tabs": false, + "single_quote": true, + "end_of_line": "lf", + "style_guide": "eslint-airbnb" + }, + "json": { + "tab_width": 2, + "use_tabs": false, + "parser": "json" + }, + "ignore": { + "files": [ + "node_modules/**", + "dist/**", + "build/**", + ".git/**", + "*.min.js", + "*.min.css", + ".env", + ".env.*", + ".env.example", + "coverage/**", + "*.log", + "*.lock", + "pnpm-lock.yaml", + "package-lock.json", + "yarn.lock", + "log/**", + "tmp/**", + "instances/**", + "public/uploads/**", + "*.dump", + "*.rdb", + "*.mmdb", + ".DS_Store", + "*.swp", + "*.swo", + "*.un~", + ".jest-cache", + ".idea/**", + ".vscode/**", + ".yalc/**", + "yalc.lock", + "*.local", + "prisma/migrations/**", + "prisma/mysql-migrations/**", + "prisma/postgresql-migrations/**" + ] + }, + "search": { + "exclude_patterns": [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/.git/**", + "**/coverage/**", + "**/log/**", + "**/tmp/**", + "**/instances/**", + "**/public/uploads/**", + "**/*.min.js", + "**/*.min.css", + "**/*.log", + "**/*.lock", + "**/pnpm-lock.yaml", + "**/package-lock.json", + "**/yarn.lock", + "**/*.dump", + "**/*.rdb", + "**/*.mmdb", + "**/.DS_Store", + "**/*.swp", + "**/*.swo", + "**/*.un~", + "**/.jest-cache", + "**/.idea/**", + "**/.vscode/**", + "**/.yalc/**", + "**/yalc.lock", + "**/*.local", + "**/prisma/migrations/**", + "**/prisma/mysql-migrations/**", + "**/prisma/postgresql-migrations/**" + ] + }, + "evolution_api": { + "project_type": "nodejs_typescript_api", + "backend_framework": "express_prisma", + "database": ["postgresql", "mysql"], + "cache": ["redis", "node_cache"], + "queue": ["rabbitmq", "sqs"], + "real_time": "socket_io", + "file_storage": ["aws_s3", "minio"], + "validation": "class_validator", + "logging": "pino", + "main_directories": { + "source": "src/", + "api": "src/api/", + "controllers": "src/api/controllers/", + "services": "src/api/services/", + "integrations": "src/api/integrations/", + "dto": "src/api/dto/", + "types": "src/api/types/", + "guards": "src/api/guards/", + "routes": "src/api/routes/", + "cache": "src/cache/", + "config": "src/config/", + "utils": "src/utils/", + "exceptions": "src/exceptions/", + "validate": "src/validate/", + "prisma": "prisma/", + "tests": "test/", + "docs": "docs/" + }, + "key_patterns": [ + "whatsapp_integration", + "multi_database_support", + "instance_management", + "event_driven_architecture", + "service_layer_pattern", + "dto_validation", + "webhook_processing", + "message_queuing", + "real_time_communication", + "file_storage_integration" + ], + "whatsapp_integrations": [ + "baileys", + "meta_business_api", + "whatsapp_cloud_api" + ], + "external_integrations": [ + "chatwoot", + "typebot", + "openai", + "dify", + "rabbitmq", + "sqs", + "s3", + "minio" + ] + } + } +} diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc new file mode 100644 index 00000000..c3523e42 --- /dev/null +++ b/.cursor/rules/project-context.mdc @@ -0,0 +1,174 @@ +--- +description: Evolution API project-specific context and constraints +globs: +alwaysApply: true +--- + +# Evolution API Project Context + +## Cross-References +- **Core Development**: @core-development.mdc for fundamental development principles +- **Specialized Rules**: Reference specific specialized rules when working on: + - Services: @specialized-rules/service-rules.mdc + - Controllers: @specialized-rules/controller-rules.mdc + - DTOs: @specialized-rules/dto-rules.mdc + - Guards: @specialized-rules/guard-rules.mdc + - Routes: @specialized-rules/route-rules.mdc + - Types: @specialized-rules/type-rules.mdc + - Utils: @specialized-rules/util-rules.mdc + - Validation: @specialized-rules/validate-rules.mdc + - Channel Integrations: @specialized-rules/integration-channel-rules.mdc + - Chatbot Integrations: @specialized-rules/integration-chatbot-rules.mdc + - Storage Integrations: @specialized-rules/integration-storage-rules.mdc + - Event Integrations: @specialized-rules/integration-event-rules.mdc +- **TypeScript/Node.js**: Node.js 20+ + TypeScript 5+ backend standards +- **Express/Prisma**: Express.js + Prisma ORM patterns +- **WhatsApp Integrations**: Baileys, Meta Business API, and other messaging platforms + +## Technology Stack +- **Backend**: Node.js 20+ + TypeScript 5+ + Express.js +- **Database**: Prisma ORM (PostgreSQL/MySQL support) +- **Cache**: Redis + Node-cache for local fallback +- **Queue**: RabbitMQ + Amazon SQS for message processing +- **Real-time**: Socket.io for WebSocket connections +- **Storage**: AWS S3 + Minio for file storage +- **Validation**: class-validator for input validation +- **Logging**: Pino for structured logging +- **Architecture**: Multi-tenant API with WhatsApp integrations + +## Project-Specific Patterns + +### WhatsApp Integration Architecture +- **MANDATORY**: All WhatsApp integrations must follow established patterns +- **BAILEYS**: Use `whatsapp.baileys.service.ts` patterns for WhatsApp Web +- **META BUSINESS**: Use `whatsapp.business.service.ts` for official API +- **CONNECTION MANAGEMENT**: One connection per instance with proper lifecycle +- **EVENT HANDLING**: Proper event listeners and error handling + +### Multi-Database Architecture +- **CRITICAL**: Support both PostgreSQL and MySQL +- **SCHEMAS**: Use appropriate schema files (postgresql-schema.prisma / mysql-schema.prisma) +- **MIGRATIONS**: Keep migrations synchronized between databases +- **TYPES**: Use database-specific types (@db.JsonB vs @db.Json) +- **COMPATIBILITY**: Ensure feature parity between databases + +### API Integration Workflow +- **CORE FEATURE**: REST API for WhatsApp communication +- **COMPLEXITY**: High - involves webhook processing, message routing, and instance management +- **COMPONENTS**: Instance management, message handling, media processing +- **INTEGRATIONS**: Baileys, Meta Business API, Chatwoot, Typebot, OpenAI, Dify + +### Multi-Tenant Instance Architecture +- **CRITICAL**: All operations must be scoped by instance +- **ISOLATION**: Complete data isolation between instances +- **SECURITY**: Validate instance ownership before operations +- **SCALING**: Support thousands of concurrent instances +- **AUTHENTICATION**: API key-based authentication per instance + +## Documentation Requirements + +### Implementation Documentation +- **MANDATORY**: Document complex integration patterns +- **LOCATION**: Use inline comments for business logic +- **API DOCS**: Document all public endpoints +- **WEBHOOK DOCS**: Document webhook payloads and signatures + +### Change Documentation +- **CHANGELOG**: Document breaking changes +- **MIGRATION GUIDES**: Document database migrations +- **INTEGRATION GUIDES**: Document new integration patterns + +## Environment and Security + +### Environment Variables +- **CRITICAL**: Never hardcode sensitive values +- **VALIDATION**: Validate required environment variables on startup +- **SECURITY**: Use secure defaults and proper encryption +- **DOCUMENTATION**: Document all environment variables + +### File Organization - Node.js/TypeScript Structure +- **CONTROLLERS**: Organized by feature (`api/controllers/`) +- **SERVICES**: Business logic in service classes (`api/services/`) +- **INTEGRATIONS**: External integrations (`api/integrations/`) +- **DTOS**: Data transfer objects (`api/dto/`) +- **TYPES**: TypeScript types (`api/types/`) +- **UTILS**: Utility functions (`utils/`) + +## Integration Points + +### WhatsApp Providers +- **BAILEYS**: WhatsApp Web integration with QR code +- **META BUSINESS**: Official WhatsApp Business API +- **CLOUD API**: WhatsApp Cloud API integration +- **WEBHOOK PROCESSING**: Proper webhook validation and processing + +### External Integrations +- **CHATWOOT**: Customer support platform integration +- **TYPEBOT**: Chatbot flow integration +- **OPENAI**: AI-powered chat integration +- **DIFY**: AI workflow integration +- **STORAGE**: S3/Minio for media file storage + +### Event-Driven Communication +- **EVENTEMITTER2**: Internal event system +- **SOCKET.IO**: Real-time WebSocket communication +- **RABBITMQ**: Message queue for async processing +- **SQS**: Amazon SQS for cloud-based queuing +- **WEBHOOKS**: Outbound webhook system + +## Development Constraints + +### Language Requirements +- **USER COMMUNICATION**: Always respond in Portuguese (PT-BR) +- **CODE/COMMENTS**: English for code and technical documentation +- **API RESPONSES**: English for consistency +- **ERROR MESSAGES**: Portuguese for user-facing errors + +### Performance Constraints +- **MEMORY**: Efficient memory usage for multiple instances +- **DATABASE**: Optimized queries with proper indexing +- **CACHE**: Strategic caching for frequently accessed data +- **CONNECTIONS**: Proper connection pooling and management + +### Security Constraints +- **AUTHENTICATION**: API key validation for all endpoints +- **AUTHORIZATION**: Instance-based access control +- **INPUT VALIDATION**: Validate all inputs with class-validator +- **RATE LIMITING**: Prevent abuse with rate limiting +- **WEBHOOK SECURITY**: Validate webhook signatures + +## Quality Standards +- **TYPE SAFETY**: Full TypeScript coverage with strict mode +- **ERROR HANDLING**: Comprehensive error scenarios with proper logging +- **TESTING**: Unit and integration tests for critical paths +- **MONITORING**: Proper logging and error tracking +- **DOCUMENTATION**: Clear API documentation and code comments +- **PERFORMANCE**: Optimized for high-throughput message processing +- **SECURITY**: Secure by default with proper validation +- **SCALABILITY**: Design for horizontal scaling + +## Evolution API Specific Development Patterns + +### Instance Management +- **LIFECYCLE**: Proper instance creation, connection, and cleanup +- **STATE MANAGEMENT**: Track connection status and health +- **RECOVERY**: Automatic reconnection and error recovery +- **MONITORING**: Health checks and status reporting + +### Message Processing +- **QUEUE-BASED**: Use queues for message processing +- **RETRY LOGIC**: Implement exponential backoff for failures +- **MEDIA HANDLING**: Proper media upload and processing +- **WEBHOOK DELIVERY**: Reliable webhook delivery with retries + +### Integration Patterns +- **SERVICE LAYER**: Business logic in service classes +- **DTO VALIDATION**: Input validation with class-validator +- **ERROR HANDLING**: Consistent error responses +- **LOGGING**: Structured logging with correlation IDs + +### Database Patterns +- **PRISMA**: Use Prisma ORM for all database operations +- **TRANSACTIONS**: Use transactions for multi-step operations +- **MIGRATIONS**: Proper migration management +- **INDEXING**: Optimize queries with appropriate indexes \ No newline at end of file diff --git a/.cursor/rules/specialized-rules/controller-rules.mdc b/.cursor/rules/specialized-rules/controller-rules.mdc new file mode 100644 index 00000000..4e4d666a --- /dev/null +++ b/.cursor/rules/specialized-rules/controller-rules.mdc @@ -0,0 +1,342 @@ +--- +description: Controller patterns for Evolution API +globs: + - "src/api/controllers/**/*.ts" + - "src/api/integrations/**/controllers/*.ts" +alwaysApply: false +--- + +# Evolution API Controller Rules + +## Controller Structure Pattern + +### Standard Controller Class +```typescript +export class ExampleController { + constructor(private readonly exampleService: ExampleService) {} + + public async createExample(instance: InstanceDto, data: ExampleDto) { + return this.exampleService.create(instance, data); + } + + public async findExample(instance: InstanceDto) { + return this.exampleService.find(instance); + } +} +``` + +## Dependency Injection Pattern + +### Service Injection +```typescript +// CORRECT - Evolution API pattern +export class ChatController { + constructor(private readonly waMonitor: WAMonitoringService) {} + + public async whatsappNumber({ instanceName }: InstanceDto, data: WhatsAppNumberDto) { + return await this.waMonitor.waInstances[instanceName].getWhatsAppNumbers(data); + } +} + +// INCORRECT - Don't inject multiple services when waMonitor is sufficient +export class ChatController { + constructor( + private readonly waMonitor: WAMonitoringService, + private readonly prismaRepository: PrismaRepository, // ❌ Unnecessary if waMonitor has access + private readonly configService: ConfigService, // ❌ Unnecessary if waMonitor has access + ) {} +} +``` + +## Method Signature Pattern + +### Instance Parameter Pattern +```typescript +// CORRECT - Evolution API pattern (destructuring instanceName) +public async fetchCatalog({ instanceName }: InstanceDto, data: getCatalogDto) { + return await this.waMonitor.waInstances[instanceName].fetchCatalog(instanceName, data); +} + +// CORRECT - Alternative pattern for full instance (when using services) +public async createTemplate(instance: InstanceDto, data: TemplateDto) { + return this.templateService.create(instance, data); +} + +// INCORRECT - Don't use generic method names +public async methodName(instance: InstanceDto, data: DataDto) { // ❌ Use specific names + return this.service.performAction(instance, data); +} +``` + +## WAMonitor Access Pattern + +### Direct WAMonitor Usage +```typescript +// CORRECT - Standard pattern in controllers +export class CallController { + constructor(private readonly waMonitor: WAMonitoringService) {} + + public async offerCall({ instanceName }: InstanceDto, data: OfferCallDto) { + return await this.waMonitor.waInstances[instanceName].offerCall(data); + } +} +``` + +## Controller Registration Pattern + +### Server Module Registration +```typescript +// In server.module.ts +export const templateController = new TemplateController(templateService); +export const businessController = new BusinessController(waMonitor); +export const chatController = new ChatController(waMonitor); +export const callController = new CallController(waMonitor); +``` + +## Error Handling in Controllers + +### Let Services Handle Errors +```typescript +// CORRECT - Let service handle errors +public async fetchCatalog(instance: InstanceDto, data: getCatalogDto) { + return await this.waMonitor.waInstances[instance.instanceName].fetchCatalog(instance.instanceName, data); +} + +// INCORRECT - Don't add try-catch in controllers unless specific handling needed +public async fetchCatalog(instance: InstanceDto, data: getCatalogDto) { + try { + return await this.waMonitor.waInstances[instance.instanceName].fetchCatalog(instance.instanceName, data); + } catch (error) { + throw error; // ❌ Unnecessary try-catch + } +} +``` + +## Complex Controller Pattern + +### Instance Controller Pattern +```typescript +export class InstanceController { + constructor( + private readonly waMonitor: WAMonitoringService, + private readonly configService: ConfigService, + private readonly prismaRepository: PrismaRepository, + private readonly eventEmitter: EventEmitter2, + private readonly chatwootService: ChatwootService, + private readonly settingsService: SettingsService, + private readonly proxyService: ProxyController, + private readonly cache: CacheService, + private readonly chatwootCache: CacheService, + private readonly baileysCache: CacheService, + private readonly providerFiles: ProviderFiles, + ) {} + + private readonly logger = new Logger('InstanceController'); + + // Multiple methods handling different aspects + public async createInstance(data: InstanceDto) { + // Complex instance creation logic + } + + public async deleteInstance({ instanceName }: InstanceDto) { + // Complex instance deletion logic + } +} +``` + +## Channel Controller Pattern + +### Base Channel Controller +```typescript +export class ChannelController { + public prismaRepository: PrismaRepository; + public waMonitor: WAMonitoringService; + + constructor(prismaRepository: PrismaRepository, waMonitor: WAMonitoringService) { + this.prisma = prismaRepository; + this.monitor = waMonitor; + } + + // Getters and setters for dependency access + public set prisma(prisma: PrismaRepository) { + this.prismaRepository = prisma; + } + + public get prisma() { + return this.prismaRepository; + } + + public set monitor(waMonitor: WAMonitoringService) { + this.waMonitor = waMonitor; + } + + public get monitor() { + return this.waMonitor; + } +} +``` + +### Extended Channel Controller +```typescript +export class EvolutionController extends ChannelController implements ChannelControllerInterface { + private readonly logger = new Logger('EvolutionController'); + + constructor(prismaRepository: PrismaRepository, waMonitor: WAMonitoringService) { + super(prismaRepository, waMonitor); + } + + integrationEnabled: boolean; + + public async receiveWebhook(data: any) { + const numberId = data.numberId; + + if (!numberId) { + this.logger.error('WebhookService -> receiveWebhookEvolution -> numberId not found'); + return; + } + + const instance = await this.prismaRepository.instance.findFirst({ + where: { number: numberId }, + }); + + if (!instance) { + this.logger.error('WebhookService -> receiveWebhook -> instance not found'); + return; + } + + await this.waMonitor.waInstances[instance.name].connectToWhatsapp(data); + + return { + status: 'success', + }; + } +} +``` + +## Chatbot Controller Pattern + +### Base Chatbot Controller +```typescript +export abstract class BaseChatbotController + extends ChatbotController + implements ChatbotControllerInterface +{ + public readonly logger: Logger; + integrationEnabled: boolean; + + // Abstract methods to be implemented + protected abstract readonly integrationName: string; + protected abstract processBot(/* parameters */): Promise; + protected abstract getFallbackBotId(settings: any): string | undefined; + + constructor(prismaRepository: PrismaRepository, waMonitor: WAMonitoringService) { + super(prismaRepository, waMonitor); + } + + // Base implementation methods + public async createBot(instance: InstanceDto, data: BotData) { + // Common bot creation logic + } +} +``` + +## Method Naming Conventions + +### Standard Method Names +- `create*()` - Create operations +- `find*()` - Find operations +- `fetch*()` - Fetch from external APIs +- `send*()` - Send operations +- `receive*()` - Receive webhook/data +- `handle*()` - Handle specific actions +- `offer*()` - Offer services (like calls) + +## Return Patterns + +### Direct Return Pattern +```typescript +// CORRECT - Direct return from service +public async createTemplate(instance: InstanceDto, data: TemplateDto) { + return this.templateService.create(instance, data); +} + +// CORRECT - Direct return from waMonitor +public async offerCall({ instanceName }: InstanceDto, data: OfferCallDto) { + return await this.waMonitor.waInstances[instanceName].offerCall(data); +} +``` + +## Controller Testing Pattern + +### Unit Test Structure +```typescript +describe('ExampleController', () => { + let controller: ExampleController; + let service: jest.Mocked; + + beforeEach(() => { + const mockService = { + create: jest.fn(), + find: jest.fn(), + }; + + controller = new ExampleController(mockService as any); + service = mockService as any; + }); + + describe('createExample', () => { + it('should call service create method', async () => { + const instance = { instanceName: 'test' }; + const data = { test: 'data' }; + const expectedResult = { success: true }; + + service.create.mockResolvedValue(expectedResult); + + const result = await controller.createExample(instance, data); + + expect(service.create).toHaveBeenCalledWith(instance, data); + expect(result).toEqual(expectedResult); + }); + }); +}); +``` + +## Interface Implementation + +### Controller Interface Pattern +```typescript +export interface ChannelControllerInterface { + integrationEnabled: boolean; +} + +export interface ChatbotControllerInterface { + integrationEnabled: boolean; + createBot(instance: InstanceDto, data: any): Promise; + findBot(instance: InstanceDto): Promise; + // ... other methods +} +``` + +## Controller Organization + +### File Naming Convention +- `*.controller.ts` - Main controllers +- `*/*.controller.ts` - Integration-specific controllers + +### Method Organization +1. Constructor +2. Public methods (alphabetical order) +3. Private methods (if any) + +### Import Organization +```typescript +// DTOs first +import { InstanceDto } from '@api/dto/instance.dto'; +import { ExampleDto } from '@api/dto/example.dto'; + +// Services +import { ExampleService } from '@api/services/example.service'; + +// Types +import { WAMonitoringService } from '@api/services/monitor.service'; +``` \ No newline at end of file diff --git a/.cursor/rules/specialized-rules/dto-rules.mdc b/.cursor/rules/specialized-rules/dto-rules.mdc new file mode 100644 index 00000000..b1cae17d --- /dev/null +++ b/.cursor/rules/specialized-rules/dto-rules.mdc @@ -0,0 +1,433 @@ +--- +description: DTO patterns and validation for Evolution API +globs: + - "src/api/dto/**/*.ts" + - "src/api/integrations/**/dto/*.ts" +alwaysApply: false +--- + +# Evolution API DTO Rules + +## DTO Structure Pattern + +### Basic DTO Class +```typescript +export class ExampleDto { + name: string; + category: string; + allowCategoryChange: boolean; + language: string; + components: any; + webhookUrl?: string; +} +``` + +## Inheritance Pattern + +### DTO Inheritance +```typescript +// CORRECT - Evolution API pattern +export class InstanceDto extends IntegrationDto { + instanceName: string; + instanceId?: string; + qrcode?: boolean; + businessId?: string; + number?: string; + integration?: string; + token?: string; + status?: string; + ownerJid?: string; + profileName?: string; + profilePicUrl?: string; + + // Settings + rejectCall?: boolean; + msgCall?: string; + groupsIgnore?: boolean; + alwaysOnline?: boolean; + readMessages?: boolean; + readStatus?: boolean; + syncFullHistory?: boolean; + wavoipToken?: string; + + // Proxy settings + proxyHost?: string; + proxyPort?: string; + proxyProtocol?: string; + proxyUsername?: string; + proxyPassword?: string; + + // Webhook configuration + webhook?: { + enabled?: boolean; + events?: string[]; + headers?: JsonValue; + url?: string; + byEvents?: boolean; + base64?: boolean; + }; + + // Chatwoot integration + chatwootAccountId?: string; + chatwootConversationPending?: boolean; + chatwootAutoCreate?: boolean; + chatwootDaysLimitImportMessages?: number; + chatwootImportContacts?: boolean; + chatwootImportMessages?: boolean; + chatwootLogo?: string; + chatwootMergeBrazilContacts?: boolean; + chatwootNameInbox?: string; + chatwootOrganization?: string; + chatwootReopenConversation?: boolean; + chatwootSignMsg?: boolean; + chatwootToken?: string; + chatwootUrl?: string; +} +``` + +## Base DTO Pattern + +### Base Chatbot DTO +```typescript +/** + * Base DTO for all chatbot integrations + * Contains common properties shared by all chatbot types + */ +export class BaseChatbotDto { + enabled?: boolean; + description: string; + expire?: number; + keywordFinish?: string; + delayMessage?: number; + unknownMessage?: string; + listeningFromMe?: boolean; + stopBotFromMe?: boolean; + keepOpen?: boolean; + debounceTime?: number; + triggerType: TriggerType; + triggerOperator?: TriggerOperator; + triggerValue?: string; + ignoreJids?: string[]; + splitMessages?: boolean; + timePerChar?: number; +} + +/** + * Base settings DTO for all chatbot integrations + */ +export class BaseChatbotSettingDto { + expire?: number; + keywordFinish?: string; + delayMessage?: number; + unknownMessage?: string; + listeningFromMe?: boolean; + stopBotFromMe?: boolean; + keepOpen?: boolean; + debounceTime?: number; + ignoreJids?: string[]; + splitMessages?: boolean; + timePerChar?: number; +} +``` + +## Message DTO Patterns + +### Send Message DTOs +```typescript +export class Metadata { + number: string; + delay?: number; +} + +export class SendTextDto extends Metadata { + text: string; + linkPreview?: boolean; + mentionsEveryOne?: boolean; + mentioned?: string[]; +} + +export class SendListDto extends Metadata { + title: string; + description: string; + buttonText: string; + footerText?: string; + + sections: Section[]; +} + +export class ContactMessage { + fullName: string; + wuid: string; + phoneNumber: string; + organization?: string; + email?: string; + url?: string; +} + +export class SendTemplateDto extends Metadata { + name: string; + language: string; + components: any; +} +``` + +## Simple DTO Patterns + +### Basic DTOs +```typescript +export class NumberDto { + number: string; +} + +export class LabelDto { + id?: string; + name: string; + color: string; + predefinedId?: string; +} + +export class HandleLabelDto { + number: string; + labelId: string; +} + +export class ProfileNameDto { + name: string; +} + +export class WhatsAppNumberDto { + numbers: string[]; +} +``` + +## Complex DTO Patterns + +### Business DTOs +```typescript +export class getCatalogDto { + number?: string; + limit?: number; + cursor?: string; +} + +export class getCollectionsDto { + number?: string; + limit?: number; + cursor?: string; +} + +export class NumberBusiness { + number: string; + name?: string; + description?: string; + email?: string; + websites?: string[]; + latitude?: number; + longitude?: number; + address?: string; + profilehandle?: string; +} +``` + +## Settings DTO Pattern + +### Settings Configuration +```typescript +export class SettingsDto { + rejectCall?: boolean; + msgCall?: string; + groupsIgnore?: boolean; + alwaysOnline?: boolean; + readMessages?: boolean; + readStatus?: boolean; + syncFullHistory?: boolean; + wavoipToken?: string; +} + +export class ProxyDto { + host?: string; + port?: string; + protocol?: string; + username?: string; + password?: string; +} +``` + +## Presence DTO Pattern + +### WhatsApp Presence +```typescript +export class SetPresenceDto { + presence: WAPresence; +} + +export class SendPresenceDto { + number: string; + presence: WAPresence; +} +``` + +## DTO Structure (No Decorators) + +### Simple DTO Classes (Evolution API Pattern) +```typescript +// CORRECT - Evolution API pattern (no decorators) +export class ExampleDto { + name: string; + description?: string; + enabled: boolean; + items?: string[]; + timeout?: number; +} + +// INCORRECT - Don't use class-validator decorators +export class ValidatedDto { + @IsString() // ❌ Evolution API doesn't use decorators + name: string; +} +``` + +## Type Safety Patterns + +### Prisma Type Integration +```typescript +import { JsonValue } from '@prisma/client/runtime/library'; +import { WAPresence } from 'baileys'; +import { TriggerOperator, TriggerType } from '@prisma/client'; + +export class TypeSafeDto { + presence: WAPresence; + triggerType: TriggerType; + triggerOperator?: TriggerOperator; + metadata?: JsonValue; +} +``` + +## DTO Documentation + +### JSDoc Comments +```typescript +/** + * DTO for creating WhatsApp templates + * Used by Meta Business API integration + */ +export class TemplateDto { + /** Template name - must be unique */ + name: string; + + /** Template category (MARKETING, UTILITY, AUTHENTICATION) */ + category: string; + + /** Whether category can be changed after creation */ + allowCategoryChange: boolean; + + /** Language code (e.g., 'pt_BR', 'en_US') */ + language: string; + + /** Template components (header, body, footer, buttons) */ + components: any; + + /** Optional webhook URL for template status updates */ + webhookUrl?: string; +} +``` + +## DTO Naming Conventions + +### Standard Naming Patterns +- `*Dto` - Data transfer objects +- `Create*Dto` - Creation DTOs +- `Update*Dto` - Update DTOs +- `Send*Dto` - Message sending DTOs +- `Get*Dto` - Query DTOs +- `Handle*Dto` - Action DTOs + +## File Organization + +### DTO File Structure +``` +src/api/dto/ +├── instance.dto.ts # Main instance DTO +├── template.dto.ts # Template management +├── sendMessage.dto.ts # Message sending DTOs +├── chat.dto.ts # Chat operations +├── business.dto.ts # Business API DTOs +├── group.dto.ts # Group management +├── label.dto.ts # Label management +├── proxy.dto.ts # Proxy configuration +├── settings.dto.ts # Instance settings +└── call.dto.ts # Call operations +``` + +## Integration DTO Patterns + +### Chatbot Integration DTOs +```typescript +// Base for all chatbot DTOs +export class BaseChatbotDto { + enabled?: boolean; + description: string; + // ... common properties +} + +// Specific chatbot DTOs extend base +export class TypebotDto extends BaseChatbotDto { + url: string; + typebot: string; + // ... typebot-specific properties +} + +export class OpenaiDto extends BaseChatbotDto { + apiKey: string; + model: string; + // ... openai-specific properties +} +``` + +## DTO Testing Pattern + +### DTO Validation Tests +```typescript +describe('ExampleDto', () => { + it('should validate required fields', () => { + const dto = new ExampleDto(); + dto.name = 'test'; + dto.category = 'MARKETING'; + dto.allowCategoryChange = true; + dto.language = 'pt_BR'; + dto.components = {}; + + expect(dto.name).toBe('test'); + expect(dto.category).toBe('MARKETING'); + }); + + it('should handle optional fields', () => { + const dto = new ExampleDto(); + dto.name = 'test'; + dto.category = 'MARKETING'; + dto.allowCategoryChange = true; + dto.language = 'pt_BR'; + dto.components = {}; + dto.webhookUrl = 'https://example.com/webhook'; + + expect(dto.webhookUrl).toBe('https://example.com/webhook'); + }); +}); +``` + +## DTO Transformation + +### Request to DTO Mapping (Evolution API Pattern) +```typescript +// CORRECT - Evolution API uses RouterBroker dataValidate +const response = await this.dataValidate({ + request: req, + schema: exampleSchema, // JSONSchema7 + ClassRef: ExampleDto, + execute: (instance, data) => controller.method(instance, data), +}); + +// INCORRECT - Don't use class-validator +const dto = plainToClass(ExampleDto, req.body); // ❌ Not used in Evolution API +const errors = await validate(dto); // ❌ Not used in Evolution API +``` \ No newline at end of file diff --git a/.cursor/rules/specialized-rules/guard-rules.mdc b/.cursor/rules/specialized-rules/guard-rules.mdc new file mode 100644 index 00000000..d85215fe --- /dev/null +++ b/.cursor/rules/specialized-rules/guard-rules.mdc @@ -0,0 +1,416 @@ +--- +description: Guard patterns for authentication and authorization in Evolution API +globs: + - "src/api/guards/**/*.ts" +alwaysApply: false +--- + +# Evolution API Guard Rules + +## Guard Structure Pattern + +### Standard Guard Function +```typescript +import { NextFunction, Request, Response } from 'express'; +import { Logger } from '@config/logger.config'; +import { UnauthorizedException, ForbiddenException } from '@exceptions'; + +const logger = new Logger('GUARD'); + +async function guardFunction(req: Request, _: Response, next: NextFunction) { + // Guard logic here + + if (validationFails) { + throw new UnauthorizedException(); + } + + return next(); +} + +export const guardName = { guardFunction }; +``` + +## Authentication Guard Pattern + +### API Key Authentication +```typescript +async function apikey(req: Request, _: Response, next: NextFunction) { + const env = configService.get('AUTHENTICATION').API_KEY; + const key = req.get('apikey'); + const db = configService.get('DATABASE'); + + if (!key) { + throw new UnauthorizedException(); + } + + // Global API key check + if (env.KEY === key) { + return next(); + } + + // Special routes handling + if ((req.originalUrl.includes('/instance/create') || req.originalUrl.includes('/instance/fetchInstances')) && !key) { + throw new ForbiddenException('Missing global api key', 'The global api key must be set'); + } + + const param = req.params as unknown as InstanceDto; + + try { + if (param?.instanceName) { + const instance = await prismaRepository.instance.findUnique({ + where: { name: param.instanceName }, + }); + if (instance.token === key) { + return next(); + } + } else { + if (req.originalUrl.includes('/instance/fetchInstances') && db.SAVE_DATA.INSTANCE) { + const instanceByKey = await prismaRepository.instance.findFirst({ + where: { token: key }, + }); + if (instanceByKey) { + return next(); + } + } + } + } catch (error) { + logger.error(error); + } + + throw new UnauthorizedException(); +} + +export const authGuard = { apikey }; +``` + +## Instance Validation Guards + +### Instance Exists Guard +```typescript +async function getInstance(instanceName: string) { + try { + const cacheConf = configService.get('CACHE'); + + const exists = !!waMonitor.waInstances[instanceName]; + + if (cacheConf.REDIS.ENABLED && cacheConf.REDIS.SAVE_INSTANCES) { + const keyExists = await cache.has(instanceName); + return exists || keyExists; + } + + return exists || (await prismaRepository.instance.findMany({ where: { name: instanceName } })).length > 0; + } catch (error) { + throw new InternalServerErrorException(error?.toString()); + } +} + +export async function instanceExistsGuard(req: Request, _: Response, next: NextFunction) { + if (req.originalUrl.includes('/instance/create')) { + return next(); + } + + const param = req.params as unknown as InstanceDto; + if (!param?.instanceName) { + throw new BadRequestException('"instanceName" not provided.'); + } + + if (!(await getInstance(param.instanceName))) { + throw new NotFoundException(`The "${param.instanceName}" instance does not exist`); + } + + next(); +} +``` + +### Instance Logged Guard +```typescript +export async function instanceLoggedGuard(req: Request, _: Response, next: NextFunction) { + if (req.originalUrl.includes('/instance/create')) { + const instance = req.body as InstanceDto; + if (await getInstance(instance.instanceName)) { + throw new ForbiddenException(`This name "${instance.instanceName}" is already in use.`); + } + + if (waMonitor.waInstances[instance.instanceName]) { + delete waMonitor.waInstances[instance.instanceName]; + } + } + + next(); +} +``` + +## Telemetry Guard Pattern + +### Telemetry Collection +```typescript +class Telemetry { + public collectTelemetry(req: Request, res: Response, next: NextFunction): void { + // Collect telemetry data + const telemetryData = { + route: req.originalUrl, + method: req.method, + timestamp: new Date(), + userAgent: req.get('User-Agent'), + }; + + // Send telemetry asynchronously (don't block request) + setImmediate(() => { + this.sendTelemetry(telemetryData); + }); + + next(); + } + + private async sendTelemetry(data: any): Promise { + try { + // Send telemetry data + } catch (error) { + // Silently fail - don't affect main request + } + } +} + +export default Telemetry; +``` + +## Guard Composition Pattern + +### Multiple Guards Usage +```typescript +// In router setup +const guards = [instanceExistsGuard, instanceLoggedGuard, authGuard['apikey']]; + +router + .use('/instance', new InstanceRouter(configService, ...guards).router) + .use('/message', new MessageRouter(...guards).router) + .use('/chat', new ChatRouter(...guards).router); +``` + +## Error Handling in Guards + +### Proper Exception Throwing +```typescript +// CORRECT - Use proper HTTP exceptions +if (!apiKey) { + throw new UnauthorizedException('API key required'); +} + +if (instanceExists) { + throw new ForbiddenException('Instance already exists'); +} + +if (!instanceFound) { + throw new NotFoundException('Instance not found'); +} + +if (validationFails) { + throw new BadRequestException('Invalid request parameters'); +} + +// INCORRECT - Don't use generic Error +if (!apiKey) { + throw new Error('API key required'); // ❌ Use specific exceptions +} +``` + +## Configuration Access in Guards + +### Config Service Usage +```typescript +async function configAwareGuard(req: Request, _: Response, next: NextFunction) { + const authConfig = configService.get('AUTHENTICATION'); + const cacheConfig = configService.get('CACHE'); + const dbConfig = configService.get('DATABASE'); + + // Use configuration for guard logic + if (authConfig.API_KEY.KEY === providedKey) { + return next(); + } + + throw new UnauthorizedException(); +} +``` + +## Database Access in Guards + +### Prisma Repository Usage +```typescript +async function databaseGuard(req: Request, _: Response, next: NextFunction) { + try { + const param = req.params as unknown as InstanceDto; + + const instance = await prismaRepository.instance.findUnique({ + where: { name: param.instanceName }, + }); + + if (!instance) { + throw new NotFoundException('Instance not found'); + } + + // Additional validation logic + if (instance.status !== 'active') { + throw new ForbiddenException('Instance not active'); + } + + return next(); + } catch (error) { + logger.error('Database guard error:', error); + throw new InternalServerErrorException('Database access failed'); + } +} +``` + +## Cache Integration in Guards + +### Cache Service Usage +```typescript +async function cacheAwareGuard(req: Request, _: Response, next: NextFunction) { + const cacheConf = configService.get('CACHE'); + + if (cacheConf.REDIS.ENABLED) { + const cached = await cache.get(`guard:${req.params.instanceName}`); + if (cached) { + // Use cached validation result + return next(); + } + } + + // Perform validation and cache result + const isValid = await performValidation(req.params.instanceName); + + if (cacheConf.REDIS.ENABLED) { + await cache.set(`guard:${req.params.instanceName}`, isValid, 300); // 5 min TTL + } + + if (isValid) { + return next(); + } + + throw new UnauthorizedException(); +} +``` + +## Logging in Guards + +### Structured Logging +```typescript +const logger = new Logger('GUARD'); + +async function loggedGuard(req: Request, _: Response, next: NextFunction) { + logger.log(`Guard validation started for ${req.originalUrl}`); + + try { + // Guard logic + const isValid = await validateRequest(req); + + if (isValid) { + logger.log(`Guard validation successful for ${req.params.instanceName}`); + return next(); + } + + logger.warn(`Guard validation failed for ${req.params.instanceName}`); + throw new UnauthorizedException(); + } catch (error) { + logger.error(`Guard validation error: ${error.message}`, error.stack); + throw error; + } +} +``` + +## Guard Testing Pattern + +### Unit Test Structure +```typescript +describe('authGuard', () => { + let req: Partial; + let res: Partial; + let next: NextFunction; + + beforeEach(() => { + req = { + get: jest.fn(), + params: {}, + originalUrl: '/test', + }; + res = {}; + next = jest.fn(); + }); + + describe('apikey', () => { + it('should pass with valid global API key', async () => { + (req.get as jest.Mock).mockReturnValue('valid-global-key'); + + await authGuard.apikey(req as Request, res as Response, next); + + expect(next).toHaveBeenCalled(); + }); + + it('should throw UnauthorizedException with no API key', async () => { + (req.get as jest.Mock).mockReturnValue(undefined); + + await expect( + authGuard.apikey(req as Request, res as Response, next) + ).rejects.toThrow(UnauthorizedException); + }); + + it('should pass with valid instance token', async () => { + (req.get as jest.Mock).mockReturnValue('instance-token'); + req.params = { instanceName: 'test-instance' }; + + // Mock prisma repository + jest.spyOn(prismaRepository.instance, 'findUnique').mockResolvedValue({ + token: 'instance-token', + } as any); + + await authGuard.apikey(req as Request, res as Response, next); + + expect(next).toHaveBeenCalled(); + }); + }); +}); +``` + +## Guard Performance Considerations + +### Efficient Validation +```typescript +// CORRECT - Efficient guard with early returns +async function efficientGuard(req: Request, _: Response, next: NextFunction) { + // Quick checks first + if (req.originalUrl.includes('/public')) { + return next(); // Skip validation for public routes + } + + const apiKey = req.get('apikey'); + if (!apiKey) { + throw new UnauthorizedException(); // Fail fast + } + + // More expensive checks only if needed + if (apiKey === globalKey) { + return next(); // Skip database check + } + + // Database check only as last resort + const isValid = await validateInDatabase(apiKey); + if (isValid) { + return next(); + } + + throw new UnauthorizedException(); +} + +// INCORRECT - Inefficient guard +async function inefficientGuard(req: Request, _: Response, next: NextFunction) { + // Always do expensive database check first + const dbResult = await expensiveDatabaseQuery(); // ❌ Expensive operation first + + const apiKey = req.get('apikey'); + if (!apiKey && dbResult) { // ❌ Complex logic + throw new UnauthorizedException(); + } + + next(); +} +``` \ No newline at end of file diff --git a/.cursor/rules/specialized-rules/integration-channel-rules.mdc b/.cursor/rules/specialized-rules/integration-channel-rules.mdc new file mode 100644 index 00000000..d2b94d96 --- /dev/null +++ b/.cursor/rules/specialized-rules/integration-channel-rules.mdc @@ -0,0 +1,552 @@ +--- +description: Channel integration patterns for Evolution API +globs: + - "src/api/integrations/channel/**/*.ts" +alwaysApply: false +--- + +# Evolution API Channel Integration Rules + +## Channel Controller Pattern + +### Base Channel Controller +```typescript +export interface ChannelControllerInterface { + integrationEnabled: boolean; +} + +export class ChannelController { + public prismaRepository: PrismaRepository; + public waMonitor: WAMonitoringService; + + constructor(prismaRepository: PrismaRepository, waMonitor: WAMonitoringService) { + this.prisma = prismaRepository; + this.monitor = waMonitor; + } + + public set prisma(prisma: PrismaRepository) { + this.prismaRepository = prisma; + } + + public get prisma() { + return this.prismaRepository; + } + + public set monitor(waMonitor: WAMonitoringService) { + this.waMonitor = waMonitor; + } + + public get monitor() { + return this.waMonitor; + } + + public init(instanceData: InstanceDto, data: ChannelDataType) { + if (!instanceData.token && instanceData.integration === Integration.WHATSAPP_BUSINESS) { + throw new BadRequestException('token is required'); + } + + if (instanceData.integration === Integration.WHATSAPP_BUSINESS) { + return new BusinessStartupService(/* dependencies */); + } + + if (instanceData.integration === Integration.EVOLUTION) { + return new EvolutionStartupService(/* dependencies */); + } + + if (instanceData.integration === Integration.WHATSAPP_BAILEYS) { + return new BaileysStartupService(/* dependencies */); + } + + return null; + } +} +``` + +### Extended Channel Controller +```typescript +export class EvolutionController extends ChannelController implements ChannelControllerInterface { + private readonly logger = new Logger('EvolutionController'); + + constructor(prismaRepository: PrismaRepository, waMonitor: WAMonitoringService) { + super(prismaRepository, waMonitor); + } + + integrationEnabled: boolean; + + public async receiveWebhook(data: any) { + const numberId = data.numberId; + + if (!numberId) { + this.logger.error('WebhookService -> receiveWebhookEvolution -> numberId not found'); + return; + } + + const instance = await this.prismaRepository.instance.findFirst({ + where: { number: numberId }, + }); + + if (!instance) { + this.logger.error('WebhookService -> receiveWebhook -> instance not found'); + return; + } + + await this.waMonitor.waInstances[instance.name].connectToWhatsapp(data); + + return { + status: 'success', + }; + } +} +``` + +## Channel Service Pattern + +### Base Channel Service +```typescript +export class ChannelStartupService { + constructor( + private readonly configService: ConfigService, + private readonly eventEmitter: EventEmitter2, + private readonly prismaRepository: PrismaRepository, + public readonly cache: CacheService, + public readonly chatwootCache: CacheService, + ) {} + + public readonly logger = new Logger('ChannelStartupService'); + + public client: WASocket; + public readonly instance: wa.Instance = {}; + public readonly localChatwoot: wa.LocalChatwoot = {}; + public readonly localProxy: wa.LocalProxy = {}; + public readonly localSettings: wa.LocalSettings = {}; + public readonly localWebhook: wa.LocalWebHook = {}; + + public setInstance(instance: InstanceDto) { + this.logger.setInstance(instance.instanceName); + + this.instance.name = instance.instanceName; + this.instance.id = instance.instanceId; + this.instance.integration = instance.integration; + this.instance.number = instance.number; + this.instance.token = instance.token; + this.instance.businessId = instance.businessId; + + if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { + this.chatwootService.eventWhatsapp( + Events.STATUS_INSTANCE, + { instanceName: this.instance.name }, + { + instance: this.instance.name, + status: 'created', + }, + ); + } + } + + public set instanceName(name: string) { + this.logger.setInstance(name); + this.instance.name = name; + } + + public get instanceName() { + return this.instance.name; + } +} +``` + +### Extended Channel Service +```typescript +export class EvolutionStartupService extends ChannelStartupService { + constructor( + configService: ConfigService, + eventEmitter: EventEmitter2, + prismaRepository: PrismaRepository, + cache: CacheService, + chatwootCache: CacheService, + ) { + super(configService, eventEmitter, prismaRepository, cache, chatwootCache); + } + + public async sendMessage(data: SendTextDto): Promise { + // Evolution-specific message sending logic + const response = await this.evolutionApiCall('/send-message', data); + return response; + } + + public async connectToWhatsapp(data: any): Promise { + // Evolution-specific connection logic + this.logger.log('Connecting to Evolution API'); + + // Set up webhook listeners + this.setupWebhookHandlers(); + + // Initialize connection + await this.initializeConnection(data); + } + + private async evolutionApiCall(endpoint: string, data: any): Promise { + const config = this.configService.get('EVOLUTION'); + + try { + const response = await axios.post(`${config.API_URL}${endpoint}`, data, { + headers: { + 'Authorization': `Bearer ${this.instance.token}`, + 'Content-Type': 'application/json', + }, + }); + + return response.data; + } catch (error) { + this.logger.error(`Evolution API call failed: ${error.message}`); + throw new InternalServerErrorException('Evolution API call failed'); + } + } + + private setupWebhookHandlers(): void { + // Set up webhook event handlers + } + + private async initializeConnection(data: any): Promise { + // Initialize connection with Evolution API + } +} +``` + +## Business API Service Pattern + +### Meta Business Service +```typescript +export class BusinessStartupService extends ChannelStartupService { + constructor( + configService: ConfigService, + eventEmitter: EventEmitter2, + prismaRepository: PrismaRepository, + cache: CacheService, + chatwootCache: CacheService, + baileysCache: CacheService, + providerFiles: ProviderFiles, + ) { + super(configService, eventEmitter, prismaRepository, cache, chatwootCache); + } + + public async sendMessage(data: SendTextDto): Promise { + const businessConfig = this.configService.get('WA_BUSINESS'); + + const payload = { + messaging_product: 'whatsapp', + to: data.number, + type: 'text', + text: { + body: data.text, + }, + }; + + try { + const response = await axios.post( + `${businessConfig.URL}/${businessConfig.VERSION}/${this.instance.businessId}/messages`, + payload, + { + headers: { + 'Authorization': `Bearer ${this.instance.token}`, + 'Content-Type': 'application/json', + }, + } + ); + + return response.data; + } catch (error) { + this.logger.error(`Business API call failed: ${error.message}`); + throw new BadRequestException('Failed to send message via Business API'); + } + } + + public async receiveWebhook(data: any): Promise { + // Process incoming webhook from Meta Business API + const { entry } = data; + + for (const entryItem of entry) { + const { changes } = entryItem; + + for (const change of changes) { + if (change.field === 'messages') { + await this.processMessage(change.value); + } + } + } + } + + private async processMessage(messageData: any): Promise { + // Process incoming message from Business API + const { messages, contacts } = messageData; + + if (messages) { + for (const message of messages) { + await this.handleIncomingMessage(message, contacts); + } + } + } + + private async handleIncomingMessage(message: any, contacts: any[]): Promise { + // Handle individual message + const contact = contacts?.find(c => c.wa_id === message.from); + + // Emit event for message processing + this.eventEmitter.emit(Events.MESSAGES_UPSERT, { + instanceName: this.instance.name, + message, + contact, + }); + } +} +``` + +## Baileys Service Pattern + +### Baileys Integration Service +```typescript +export class BaileysStartupService extends ChannelStartupService { + constructor( + configService: ConfigService, + eventEmitter: EventEmitter2, + prismaRepository: PrismaRepository, + cache: CacheService, + chatwootCache: CacheService, + baileysCache: CacheService, + providerFiles: ProviderFiles, + ) { + super(configService, eventEmitter, prismaRepository, cache, chatwootCache); + } + + public async connectToWhatsapp(): Promise { + const authPath = path.join(INSTANCE_DIR, this.instance.name); + const { state, saveCreds } = await useMultiFileAuthState(authPath); + + this.client = makeWASocket({ + auth: state, + logger: P({ level: 'error' }), + printQRInTerminal: false, + browser: ['Evolution API', 'Chrome', '4.0.0'], + defaultQueryTimeoutMs: 60000, + }); + + this.setupEventHandlers(); + this.client.ev.on('creds.update', saveCreds); + } + + private setupEventHandlers(): void { + this.client.ev.on('connection.update', (update) => { + this.handleConnectionUpdate(update); + }); + + this.client.ev.on('messages.upsert', ({ messages, type }) => { + this.handleIncomingMessages(messages, type); + }); + + this.client.ev.on('messages.update', (updates) => { + this.handleMessageUpdates(updates); + }); + + this.client.ev.on('contacts.upsert', (contacts) => { + this.handleContactsUpdate(contacts); + }); + + this.client.ev.on('chats.upsert', (chats) => { + this.handleChatsUpdate(chats); + }); + } + + private async handleConnectionUpdate(update: ConnectionUpdate): Promise { + const { connection, lastDisconnect, qr } = update; + + if (qr) { + this.instance.qrcode = { + count: this.instance.qrcode?.count ? this.instance.qrcode.count + 1 : 1, + base64: qr, + }; + + this.eventEmitter.emit(Events.QRCODE_UPDATED, { + instanceName: this.instance.name, + qrcode: this.instance.qrcode, + }); + } + + if (connection === 'close') { + const shouldReconnect = (lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut; + + if (shouldReconnect) { + this.logger.log('Connection closed, reconnecting...'); + await this.connectToWhatsapp(); + } else { + this.logger.log('Connection closed, logged out'); + this.eventEmitter.emit(Events.LOGOUT_INSTANCE, { + instanceName: this.instance.name, + }); + } + } + + if (connection === 'open') { + this.logger.log('Connection opened successfully'); + this.instance.wuid = this.client.user?.id; + + this.eventEmitter.emit(Events.CONNECTION_UPDATE, { + instanceName: this.instance.name, + state: 'open', + }); + } + } + + public async sendMessage(data: SendTextDto): Promise { + const jid = createJid(data.number); + + const message = { + text: data.text, + }; + + if (data.linkPreview !== undefined) { + message.linkPreview = data.linkPreview; + } + + if (data.mentionsEveryOne) { + // Handle mentions + } + + try { + const response = await this.client.sendMessage(jid, message); + return response; + } catch (error) { + this.logger.error(`Failed to send message: ${error.message}`); + throw new BadRequestException('Failed to send message'); + } + } +} +``` + +## Channel Router Pattern + +### Channel Router Structure +```typescript +export class ChannelRouter { + public readonly router: Router; + + constructor(configService: any, ...guards: any[]) { + this.router = Router(); + + this.router.use('/', new EvolutionRouter(configService).router); + this.router.use('/', new MetaRouter(configService).router); + this.router.use('/baileys', new BaileysRouter(...guards).router); + } +} +``` + +### Specific Channel Router +```typescript +export class EvolutionRouter extends RouterBroker { + constructor(private readonly configService: ConfigService) { + super(); + this.router + .post(this.routerPath('webhook'), async (req, res) => { + const response = await evolutionController.receiveWebhook(req.body); + return res.status(HttpStatus.OK).json(response); + }); + } + + public readonly router: Router = Router(); +} +``` + +## Integration Types + +### Channel Data Types +```typescript +type ChannelDataType = { + configService: ConfigService; + eventEmitter: EventEmitter2; + prismaRepository: PrismaRepository; + cache: CacheService; + chatwootCache: CacheService; + baileysCache: CacheService; + providerFiles: ProviderFiles; +}; + +export enum Integration { + WHATSAPP_BUSINESS = 'WHATSAPP-BUSINESS', + WHATSAPP_BAILEYS = 'WHATSAPP-BAILEYS', + EVOLUTION = 'EVOLUTION', +} +``` + +## Error Handling in Channels + +### Channel-Specific Error Handling +```typescript +// CORRECT - Channel-specific error handling +public async sendMessage(data: SendTextDto): Promise { + try { + const response = await this.channelSpecificSend(data); + return response; + } catch (error) { + this.logger.error(`${this.constructor.name} send failed: ${error.message}`); + + if (error.response?.status === 401) { + throw new UnauthorizedException('Invalid token for channel'); + } + + if (error.response?.status === 429) { + throw new BadRequestException('Rate limit exceeded'); + } + + throw new InternalServerErrorException('Channel communication failed'); + } +} +``` + +## Channel Testing Pattern + +### Channel Service Testing +```typescript +describe('EvolutionStartupService', () => { + let service: EvolutionStartupService; + let configService: jest.Mocked; + let eventEmitter: jest.Mocked; + + beforeEach(() => { + const mockConfig = { + get: jest.fn().mockReturnValue({ + API_URL: 'https://api.evolution.com', + }), + }; + + service = new EvolutionStartupService( + mockConfig as any, + eventEmitter, + prismaRepository, + cache, + chatwootCache, + ); + }); + + describe('sendMessage', () => { + it('should send message successfully', async () => { + const data = { number: '5511999999999', text: 'Test message' }; + + // Mock axios response + jest.spyOn(axios, 'post').mockResolvedValue({ + data: { success: true, messageId: '123' }, + }); + + const result = await service.sendMessage(data); + + expect(result.success).toBe(true); + expect(axios.post).toHaveBeenCalledWith( + expect.stringContaining('/send-message'), + data, + expect.objectContaining({ + headers: expect.objectContaining({ + 'Authorization': expect.stringContaining('Bearer'), + }), + }) + ); + }); + }); +}); +``` \ No newline at end of file diff --git a/.cursor/rules/specialized-rules/integration-chatbot-rules.mdc b/.cursor/rules/specialized-rules/integration-chatbot-rules.mdc new file mode 100644 index 00000000..ff511754 --- /dev/null +++ b/.cursor/rules/specialized-rules/integration-chatbot-rules.mdc @@ -0,0 +1,597 @@ +--- +description: Chatbot integration patterns for Evolution API +globs: + - "src/api/integrations/chatbot/**/*.ts" +alwaysApply: false +--- + +# Evolution API Chatbot Integration Rules + +## Base Chatbot Pattern + +### Base Chatbot DTO +```typescript +/** + * Base DTO for all chatbot integrations + * Contains common properties shared by all chatbot types + */ +export class BaseChatbotDto { + enabled?: boolean; + description: string; + expire?: number; + keywordFinish?: string; + delayMessage?: number; + unknownMessage?: string; + listeningFromMe?: boolean; + stopBotFromMe?: boolean; + keepOpen?: boolean; + debounceTime?: number; + triggerType: TriggerType; + triggerOperator?: TriggerOperator; + triggerValue?: string; + ignoreJids?: string[]; + splitMessages?: boolean; + timePerChar?: number; +} +``` + +### Base Chatbot Controller +```typescript +export abstract class BaseChatbotController + extends ChatbotController + implements ChatbotControllerInterface +{ + public readonly logger: Logger; + integrationEnabled: boolean; + botRepository: any; + settingsRepository: any; + sessionRepository: any; + userMessageDebounce: { [key: string]: { message: string; timeoutId: NodeJS.Timeout } } = {}; + + // Abstract methods to be implemented by specific chatbots + protected abstract readonly integrationName: string; + protected abstract processBot( + waInstance: any, + remoteJid: string, + bot: BotType, + session: any, + settings: ChatbotSettings, + content: string, + pushName?: string, + msg?: any, + ): Promise; + protected abstract getFallbackBotId(settings: any): string | undefined; + + constructor(prismaRepository: PrismaRepository, waMonitor: WAMonitoringService) { + super(prismaRepository, waMonitor); + this.sessionRepository = this.prismaRepository.integrationSession; + } + + // Base implementation methods + public async createBot(instance: InstanceDto, data: BotData) { + if (!data.enabled) { + throw new BadRequestException(`${this.integrationName} is disabled`); + } + + // Common bot creation logic + const bot = await this.botRepository.create({ + data: { + ...data, + instanceId: instance.instanceId, + }, + }); + + return bot; + } +} +``` + +### Base Chatbot Service +```typescript +/** + * Base class for all chatbot service implementations + * Contains common methods shared across different chatbot integrations + */ +export abstract class BaseChatbotService { + protected readonly logger: Logger; + protected readonly waMonitor: WAMonitoringService; + protected readonly prismaRepository: PrismaRepository; + protected readonly configService?: ConfigService; + + constructor( + waMonitor: WAMonitoringService, + prismaRepository: PrismaRepository, + loggerName: string, + configService?: ConfigService, + ) { + this.waMonitor = waMonitor; + this.prismaRepository = prismaRepository; + this.logger = new Logger(loggerName); + this.configService = configService; + } + + /** + * Check if a message contains an image + */ + protected isImageMessage(content: string): boolean { + return content.includes('imageMessage'); + } + + /** + * Extract text content from message + */ + protected getMessageContent(msg: any): string { + return getConversationMessage(msg); + } + + /** + * Send typing indicator + */ + protected async sendTyping(instanceName: string, remoteJid: string): Promise { + await this.waMonitor.waInstances[instanceName].sendPresence(remoteJid, 'composing'); + } +} +``` + +## Typebot Integration Pattern + +### Typebot Service +```typescript +export class TypebotService extends BaseChatbotService { + constructor( + waMonitor: WAMonitoringService, + configService: ConfigService, + prismaRepository: PrismaRepository, + private readonly openaiService: OpenaiService, + ) { + super(waMonitor, prismaRepository, 'TypebotService', configService); + } + + public async sendTypebotMessage( + instanceName: string, + remoteJid: string, + typebot: TypebotModel, + content: string, + ): Promise { + try { + const response = await axios.post( + `${typebot.url}/api/v1/typebots/${typebot.typebot}/startChat`, + { + message: content, + sessionId: `${instanceName}-${remoteJid}`, + }, + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + const { messages } = response.data; + + for (const message of messages) { + await this.processTypebotMessage(instanceName, remoteJid, message); + } + } catch (error) { + this.logger.error(`Typebot API error: ${error.message}`); + throw new InternalServerErrorException('Typebot communication failed'); + } + } + + private async processTypebotMessage( + instanceName: string, + remoteJid: string, + message: any, + ): Promise { + const waInstance = this.waMonitor.waInstances[instanceName]; + + if (message.type === 'text') { + await waInstance.sendMessage({ + number: remoteJid.split('@')[0], + text: message.content.richText[0].children[0].text, + }); + } + + if (message.type === 'image') { + await waInstance.sendMessage({ + number: remoteJid.split('@')[0], + mediaMessage: { + mediatype: 'image', + media: message.content.url, + }, + }); + } + } +} +``` + +## OpenAI Integration Pattern + +### OpenAI Service +```typescript +export class OpenaiService extends BaseChatbotService { + constructor( + waMonitor: WAMonitoringService, + prismaRepository: PrismaRepository, + configService: ConfigService, + ) { + super(waMonitor, prismaRepository, 'OpenaiService', configService); + } + + public async sendOpenaiMessage( + instanceName: string, + remoteJid: string, + openai: OpenaiModel, + content: string, + pushName?: string, + ): Promise { + try { + const openaiConfig = this.configService.get('OPENAI'); + + const response = await axios.post( + 'https://api.openai.com/v1/chat/completions', + { + model: openai.model || 'gpt-3.5-turbo', + messages: [ + { + role: 'system', + content: openai.systemMessage || 'You are a helpful assistant.', + }, + { + role: 'user', + content: content, + }, + ], + max_tokens: openai.maxTokens || 1000, + temperature: openai.temperature || 0.7, + }, + { + headers: { + 'Authorization': `Bearer ${openai.apiKey || openaiConfig.API_KEY}`, + 'Content-Type': 'application/json', + }, + } + ); + + const aiResponse = response.data.choices[0].message.content; + + await this.waMonitor.waInstances[instanceName].sendMessage({ + number: remoteJid.split('@')[0], + text: aiResponse, + }); + } catch (error) { + this.logger.error(`OpenAI API error: ${error.message}`); + + // Send fallback message + await this.waMonitor.waInstances[instanceName].sendMessage({ + number: remoteJid.split('@')[0], + text: openai.unknownMessage || 'Desculpe, não consegui processar sua mensagem.', + }); + } + } +} +``` + +## Chatwoot Integration Pattern + +### Chatwoot Service +```typescript +export class ChatwootService extends BaseChatbotService { + constructor( + waMonitor: WAMonitoringService, + configService: ConfigService, + prismaRepository: PrismaRepository, + private readonly chatwootCache: CacheService, + ) { + super(waMonitor, prismaRepository, 'ChatwootService', configService); + } + + public async eventWhatsapp( + event: Events, + instanceName: { instanceName: string }, + data: any, + ): Promise { + const chatwootConfig = this.configService.get('CHATWOOT'); + + if (!chatwootConfig.ENABLED) { + return; + } + + try { + const instance = await this.prismaRepository.instance.findUnique({ + where: { name: instanceName.instanceName }, + }); + + if (!instance?.chatwootAccountId) { + return; + } + + const webhook = { + event, + instance: instanceName.instanceName, + data, + timestamp: new Date().toISOString(), + }; + + await axios.post( + `${instance.chatwootUrl}/api/v1/accounts/${instance.chatwootAccountId}/webhooks`, + webhook, + { + headers: { + 'Authorization': `Bearer ${instance.chatwootToken}`, + 'Content-Type': 'application/json', + }, + } + ); + } catch (error) { + this.logger.error(`Chatwoot webhook error: ${error.message}`); + } + } + + public async createConversation( + instanceName: string, + contact: any, + message: any, + ): Promise { + // Create conversation in Chatwoot + const instance = await this.prismaRepository.instance.findUnique({ + where: { name: instanceName }, + }); + + if (!instance?.chatwootAccountId) { + return; + } + + try { + const conversation = await axios.post( + `${instance.chatwootUrl}/api/v1/accounts/${instance.chatwootAccountId}/conversations`, + { + source_id: contact.id, + inbox_id: instance.chatwootInboxId, + contact_id: contact.chatwootContactId, + }, + { + headers: { + 'Authorization': `Bearer ${instance.chatwootToken}`, + 'Content-Type': 'application/json', + }, + } + ); + + // Cache conversation + await this.chatwootCache.set( + `conversation:${instanceName}:${contact.id}`, + conversation.data, + 3600 + ); + } catch (error) { + this.logger.error(`Chatwoot conversation creation error: ${error.message}`); + } + } +} +``` + +## Dify Integration Pattern + +### Dify Service +```typescript +export class DifyService extends BaseChatbotService { + constructor( + waMonitor: WAMonitoringService, + prismaRepository: PrismaRepository, + configService: ConfigService, + private readonly openaiService: OpenaiService, + ) { + super(waMonitor, prismaRepository, 'DifyService', configService); + } + + public async sendDifyMessage( + instanceName: string, + remoteJid: string, + dify: DifyModel, + content: string, + ): Promise { + try { + const response = await axios.post( + `${dify.apiUrl}/v1/chat-messages`, + { + inputs: {}, + query: content, + user: remoteJid, + conversation_id: `${instanceName}-${remoteJid}`, + response_mode: 'blocking', + }, + { + headers: { + 'Authorization': `Bearer ${dify.apiKey}`, + 'Content-Type': 'application/json', + }, + } + ); + + const aiResponse = response.data.answer; + + await this.waMonitor.waInstances[instanceName].sendMessage({ + number: remoteJid.split('@')[0], + text: aiResponse, + }); + } catch (error) { + this.logger.error(`Dify API error: ${error.message}`); + + // Fallback to OpenAI if configured + if (dify.fallbackOpenai && this.openaiService) { + await this.openaiService.sendOpenaiMessage(instanceName, remoteJid, dify.openaiBot, content); + } + } + } +} +``` + +## Chatbot Router Pattern + +### Chatbot Router Structure (Evolution API Real Pattern) +```typescript +export class ChatbotRouter { + public readonly router: Router; + + constructor(...guards: any[]) { + this.router = Router(); + + // Real Evolution API chatbot integrations + this.router.use('/evolutionBot', new EvolutionBotRouter(...guards).router); + this.router.use('/chatwoot', new ChatwootRouter(...guards).router); + this.router.use('/typebot', new TypebotRouter(...guards).router); + this.router.use('/openai', new OpenaiRouter(...guards).router); + this.router.use('/dify', new DifyRouter(...guards).router); + this.router.use('/flowise', new FlowiseRouter(...guards).router); + this.router.use('/n8n', new N8nRouter(...guards).router); + this.router.use('/evoai', new EvoaiRouter(...guards).router); + } +} +``` + +## Chatbot Validation Patterns + +### Chatbot Schema Validation (Evolution API Pattern) +```typescript +import { JSONSchema7 } from 'json-schema'; +import { v4 } from 'uuid'; + +const isNotEmpty = (...fields: string[]) => { + const properties = {}; + fields.forEach((field) => { + properties[field] = { + if: { properties: { [field]: { type: 'string' } } }, + then: { properties: { [field]: { minLength: 1 } } }, + }; + }); + + return { + allOf: Object.values(properties), + }; +}; + +export const evolutionBotSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + enabled: { type: 'boolean' }, + description: { type: 'string' }, + apiUrl: { type: 'string' }, + apiKey: { type: 'string' }, + triggerType: { type: 'string', enum: ['all', 'keyword', 'none', 'advanced'] }, + triggerOperator: { type: 'string', enum: ['equals', 'contains', 'startsWith', 'endsWith', 'regex'] }, + triggerValue: { type: 'string' }, + expire: { type: 'integer' }, + keywordFinish: { type: 'string' }, + delayMessage: { type: 'integer' }, + unknownMessage: { type: 'string' }, + listeningFromMe: { type: 'boolean' }, + stopBotFromMe: { type: 'boolean' }, + keepOpen: { type: 'boolean' }, + debounceTime: { type: 'integer' }, + ignoreJids: { type: 'array', items: { type: 'string' } }, + splitMessages: { type: 'boolean' }, + timePerChar: { type: 'integer' }, + }, + required: ['enabled', 'apiUrl', 'triggerType'], + ...isNotEmpty('enabled', 'apiUrl', 'triggerType'), +}; + +function validateKeywordTrigger( + content: string, + operator: TriggerOperator, + value: string, +): boolean { + const normalizedContent = content.toLowerCase().trim(); + const normalizedValue = value.toLowerCase().trim(); + + switch (operator) { + case TriggerOperator.EQUALS: + return normalizedContent === normalizedValue; + case TriggerOperator.CONTAINS: + return normalizedContent.includes(normalizedValue); + case TriggerOperator.STARTS_WITH: + return normalizedContent.startsWith(normalizedValue); + case TriggerOperator.ENDS_WITH: + return normalizedContent.endsWith(normalizedValue); + default: + return false; + } +} +``` + +## Session Management Pattern + +### Chatbot Session Handling +```typescript +export class ChatbotSessionManager { + constructor( + private readonly prismaRepository: PrismaRepository, + private readonly cache: CacheService, + ) {} + + public async getSession( + instanceName: string, + remoteJid: string, + botId: string, + ): Promise { + const cacheKey = `session:${instanceName}:${remoteJid}:${botId}`; + + // Try cache first + let session = await this.cache.get(cacheKey); + if (session) { + return session; + } + + // Query database + session = await this.prismaRepository.integrationSession.findFirst({ + where: { + instanceId: instanceName, + remoteJid, + botId, + status: 'opened', + }, + }); + + // Cache result + if (session) { + await this.cache.set(cacheKey, session, 300); // 5 min TTL + } + + return session; + } + + public async createSession( + instanceName: string, + remoteJid: string, + botId: string, + ): Promise { + const session = await this.prismaRepository.integrationSession.create({ + data: { + instanceId: instanceName, + remoteJid, + botId, + status: 'opened', + createdAt: new Date(), + }, + }); + + // Cache new session + const cacheKey = `session:${instanceName}:${remoteJid}:${botId}`; + await this.cache.set(cacheKey, session, 300); + + return session; + } + + public async closeSession(sessionId: string): Promise { + await this.prismaRepository.integrationSession.update({ + where: { id: sessionId }, + data: { status: 'closed', updatedAt: new Date() }, + }); + + // Invalidate cache + // Note: In a real implementation, you'd need to track cache keys by session ID + } +} +``` \ No newline at end of file diff --git a/.cursor/rules/specialized-rules/integration-event-rules.mdc b/.cursor/rules/specialized-rules/integration-event-rules.mdc new file mode 100644 index 00000000..2687d72f --- /dev/null +++ b/.cursor/rules/specialized-rules/integration-event-rules.mdc @@ -0,0 +1,851 @@ +--- +description: Event integration patterns for Evolution API +globs: + - "src/api/integrations/event/**/*.ts" +alwaysApply: false +--- + +# Evolution API Event Integration Rules + +## Event Manager Pattern + +### Event Manager Structure +```typescript +import { PrismaRepository } from '@api/repository/repository.service'; +import { ConfigService } from '@config/env.config'; +import { Logger } from '@config/logger.config'; +import { Server } from 'http'; + +export class EventManager { + private prismaRepository: PrismaRepository; + private configService: ConfigService; + private logger = new Logger('EventManager'); + + // Event integrations + private webhook: WebhookController; + private websocket: WebsocketController; + private rabbitmq: RabbitmqController; + private nats: NatsController; + private sqs: SqsController; + private pusher: PusherController; + + constructor( + prismaRepository: PrismaRepository, + configService: ConfigService, + server?: Server, + ) { + this.prismaRepository = prismaRepository; + this.configService = configService; + + // Initialize event controllers + this.webhook = new WebhookController(prismaRepository, configService); + this.websocket = new WebsocketController(prismaRepository, configService, server); + this.rabbitmq = new RabbitmqController(prismaRepository, configService); + this.nats = new NatsController(prismaRepository, configService); + this.sqs = new SqsController(prismaRepository, configService); + this.pusher = new PusherController(prismaRepository, configService); + } + + public async emit(eventData: { + instanceName: string; + origin: string; + event: string; + data: Object; + serverUrl: string; + dateTime: string; + sender: string; + apiKey?: string; + local?: boolean; + integration?: string[]; + }): Promise { + this.logger.log(`Emitting event ${eventData.event} for instance ${eventData.instanceName}`); + + // Emit to all configured integrations + await Promise.allSettled([ + this.webhook.emit(eventData), + this.websocket.emit(eventData), + this.rabbitmq.emit(eventData), + this.nats.emit(eventData), + this.sqs.emit(eventData), + this.pusher.emit(eventData), + ]); + } + + public async setInstance(instanceName: string, data: any): Promise { + const promises = []; + + if (data.websocket) { + promises.push( + this.websocket.set(instanceName, { + websocket: { + enabled: true, + events: data.websocket?.events, + }, + }) + ); + } + + if (data.rabbitmq) { + promises.push( + this.rabbitmq.set(instanceName, { + rabbitmq: { + enabled: true, + events: data.rabbitmq?.events, + }, + }) + ); + } + + if (data.webhook) { + promises.push( + this.webhook.set(instanceName, { + webhook: { + enabled: true, + events: data.webhook?.events, + url: data.webhook?.url, + headers: data.webhook?.headers, + base64: data.webhook?.base64, + byEvents: data.webhook?.byEvents, + }, + }) + ); + } + + // Set other integrations... + + await Promise.allSettled(promises); + } +} +``` + +## Base Event Controller Pattern + +### Abstract Event Controller +```typescript +import { PrismaRepository } from '@api/repository/repository.service'; +import { ConfigService } from '@config/env.config'; +import { Logger } from '@config/logger.config'; + +export type EmitData = { + instanceName: string; + origin: string; + event: string; + data: Object; + serverUrl: string; + dateTime: string; + sender: string; + apiKey?: string; + local?: boolean; + integration?: string[]; +}; + +export interface EventControllerInterface { + integrationEnabled: boolean; + emit(data: EmitData): Promise; + set(instanceName: string, data: any): Promise; +} + +export abstract class EventController implements EventControllerInterface { + protected readonly logger: Logger; + protected readonly prismaRepository: PrismaRepository; + protected readonly configService: ConfigService; + + public integrationEnabled: boolean = false; + + // Available events for all integrations + public static readonly events = [ + 'APPLICATION_STARTUP', + 'INSTANCE_CREATE', + 'INSTANCE_DELETE', + 'QRCODE_UPDATED', + 'CONNECTION_UPDATE', + 'STATUS_INSTANCE', + 'MESSAGES_SET', + 'MESSAGES_UPSERT', + 'MESSAGES_EDITED', + 'MESSAGES_UPDATE', + 'MESSAGES_DELETE', + 'SEND_MESSAGE', + 'CONTACTS_SET', + 'CONTACTS_UPSERT', + 'CONTACTS_UPDATE', + 'PRESENCE_UPDATE', + 'CHATS_SET', + 'CHATS_UPDATE', + 'CHATS_UPSERT', + 'CHATS_DELETE', + 'GROUPS_UPSERT', + 'GROUPS_UPDATE', + 'GROUP_PARTICIPANTS_UPDATE', + 'CALL', + 'TYPEBOT_START', + 'TYPEBOT_CHANGE_STATUS', + 'LABELS_EDIT', + 'LABELS_ASSOCIATION', + 'CREDS_UPDATE', + 'MESSAGING_HISTORY_SET', + 'REMOVE_INSTANCE', + 'LOGOUT_INSTANCE', + ]; + + constructor( + prismaRepository: PrismaRepository, + configService: ConfigService, + loggerName: string, + ) { + this.prismaRepository = prismaRepository; + this.configService = configService; + this.logger = new Logger(loggerName); + } + + // Abstract methods to be implemented by specific integrations + public abstract emit(data: EmitData): Promise; + public abstract set(instanceName: string, data: any): Promise; + + // Helper method to check if event should be processed + protected shouldProcessEvent(eventName: string, configuredEvents?: string[]): boolean { + if (!configuredEvents || configuredEvents.length === 0) { + return true; // Process all events if none specified + } + return configuredEvents.includes(eventName); + } + + // Helper method to get instance configuration + protected async getInstanceConfig(instanceName: string): Promise { + try { + const instance = await this.prismaRepository.instance.findUnique({ + where: { name: instanceName }, + }); + return instance; + } catch (error) { + this.logger.error(`Failed to get instance config for ${instanceName}:`, error); + return null; + } + } +} +``` + +## Webhook Integration Pattern + +### Webhook Controller Implementation +```typescript +export class WebhookController extends EventController { + constructor( + prismaRepository: PrismaRepository, + configService: ConfigService, + ) { + super(prismaRepository, configService, 'WebhookController'); + } + + public async emit(data: EmitData): Promise { + try { + const instance = await this.getInstanceConfig(data.instanceName); + if (!instance?.webhook?.enabled) { + return; + } + + const webhookConfig = instance.webhook; + + if (!this.shouldProcessEvent(data.event, webhookConfig.events)) { + return; + } + + const payload = { + event: data.event, + instance: data.instanceName, + data: data.data, + timestamp: data.dateTime, + sender: data.sender, + server: { + version: process.env.npm_package_version, + url: data.serverUrl, + }, + }; + + // Encode data as base64 if configured + if (webhookConfig.base64) { + payload.data = Buffer.from(JSON.stringify(payload.data)).toString('base64'); + } + + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'Evolution-API-Webhook', + ...webhookConfig.headers, + }; + + if (webhookConfig.byEvents) { + // Send to event-specific endpoint + const eventUrl = `${webhookConfig.url}/${data.event.toLowerCase()}`; + await this.sendWebhook(eventUrl, payload, headers); + } else { + // Send to main webhook URL + await this.sendWebhook(webhookConfig.url, payload, headers); + } + + this.logger.log(`Webhook sent for event ${data.event} to instance ${data.instanceName}`); + } catch (error) { + this.logger.error(`Webhook emission failed for ${data.instanceName}:`, error); + } + } + + public async set(instanceName: string, data: any): Promise { + try { + const webhookData = data.webhook; + + await this.prismaRepository.instance.update({ + where: { name: instanceName }, + data: { + webhook: webhookData, + }, + }); + + this.logger.log(`Webhook configuration set for instance ${instanceName}`); + return { webhook: webhookData }; + } catch (error) { + this.logger.error(`Failed to set webhook config for ${instanceName}:`, error); + throw error; + } + } + + private async sendWebhook(url: string, payload: any, headers: any): Promise { + try { + const response = await axios.post(url, payload, { + headers, + timeout: 30000, + maxRedirects: 3, + }); + + if (response.status >= 200 && response.status < 300) { + this.logger.log(`Webhook delivered successfully to ${url}`); + } else { + this.logger.warn(`Webhook returned status ${response.status} for ${url}`); + } + } catch (error) { + this.logger.error(`Webhook delivery failed to ${url}:`, error.message); + + // Implement retry logic here if needed + if (error.response?.status >= 500) { + // Server error - might be worth retrying + this.logger.log(`Server error detected, webhook might be retried later`); + } + } + } +} +``` + +## WebSocket Integration Pattern + +### WebSocket Controller Implementation +```typescript +import { Server as SocketIOServer } from 'socket.io'; +import { Server } from 'http'; + +export class WebsocketController extends EventController { + private io: SocketIOServer; + + constructor( + prismaRepository: PrismaRepository, + configService: ConfigService, + server?: Server, + ) { + super(prismaRepository, configService, 'WebsocketController'); + + if (server) { + this.io = new SocketIOServer(server, { + cors: { + origin: "*", + methods: ["GET", "POST"], + }, + }); + + this.setupSocketHandlers(); + } + } + + private setupSocketHandlers(): void { + this.io.on('connection', (socket) => { + this.logger.log(`WebSocket client connected: ${socket.id}`); + + socket.on('join-instance', (instanceName: string) => { + socket.join(`instance:${instanceName}`); + this.logger.log(`Client ${socket.id} joined instance ${instanceName}`); + }); + + socket.on('leave-instance', (instanceName: string) => { + socket.leave(`instance:${instanceName}`); + this.logger.log(`Client ${socket.id} left instance ${instanceName}`); + }); + + socket.on('disconnect', () => { + this.logger.log(`WebSocket client disconnected: ${socket.id}`); + }); + }); + } + + public async emit(data: EmitData): Promise { + if (!this.io) { + return; + } + + try { + const instance = await this.getInstanceConfig(data.instanceName); + if (!instance?.websocket?.enabled) { + return; + } + + const websocketConfig = instance.websocket; + + if (!this.shouldProcessEvent(data.event, websocketConfig.events)) { + return; + } + + const payload = { + event: data.event, + instance: data.instanceName, + data: data.data, + timestamp: data.dateTime, + sender: data.sender, + }; + + // Emit to specific instance room + this.io.to(`instance:${data.instanceName}`).emit('evolution-event', payload); + + // Also emit to global room for monitoring + this.io.emit('global-event', payload); + + this.logger.log(`WebSocket event ${data.event} emitted for instance ${data.instanceName}`); + } catch (error) { + this.logger.error(`WebSocket emission failed for ${data.instanceName}:`, error); + } + } + + public async set(instanceName: string, data: any): Promise { + try { + const websocketData = data.websocket; + + await this.prismaRepository.instance.update({ + where: { name: instanceName }, + data: { + websocket: websocketData, + }, + }); + + this.logger.log(`WebSocket configuration set for instance ${instanceName}`); + return { websocket: websocketData }; + } catch (error) { + this.logger.error(`Failed to set WebSocket config for ${instanceName}:`, error); + throw error; + } + } +} +``` + +## Queue Integration Patterns + +### RabbitMQ Controller Implementation +```typescript +import amqp from 'amqplib'; + +export class RabbitmqController extends EventController { + private connection: amqp.Connection | null = null; + private channel: amqp.Channel | null = null; + + constructor( + prismaRepository: PrismaRepository, + configService: ConfigService, + ) { + super(prismaRepository, configService, 'RabbitmqController'); + this.initializeConnection(); + } + + private async initializeConnection(): Promise { + try { + const rabbitmqConfig = this.configService.get('RABBITMQ'); + if (!rabbitmqConfig?.ENABLED) { + return; + } + + this.connection = await amqp.connect(rabbitmqConfig.URI); + this.channel = await this.connection.createChannel(); + + // Declare exchange for Evolution API events + await this.channel.assertExchange('evolution-events', 'topic', { durable: true }); + + this.logger.log('RabbitMQ connection established'); + } catch (error) { + this.logger.error('Failed to initialize RabbitMQ connection:', error); + } + } + + public async emit(data: EmitData): Promise { + if (!this.channel) { + return; + } + + try { + const instance = await this.getInstanceConfig(data.instanceName); + if (!instance?.rabbitmq?.enabled) { + return; + } + + const rabbitmqConfig = instance.rabbitmq; + + if (!this.shouldProcessEvent(data.event, rabbitmqConfig.events)) { + return; + } + + const payload = { + event: data.event, + instance: data.instanceName, + data: data.data, + timestamp: data.dateTime, + sender: data.sender, + }; + + const routingKey = `evolution.${data.instanceName}.${data.event.toLowerCase()}`; + + await this.channel.publish( + 'evolution-events', + routingKey, + Buffer.from(JSON.stringify(payload)), + { + persistent: true, + timestamp: Date.now(), + messageId: `${data.instanceName}-${Date.now()}`, + } + ); + + this.logger.log(`RabbitMQ message published for event ${data.event} to instance ${data.instanceName}`); + } catch (error) { + this.logger.error(`RabbitMQ emission failed for ${data.instanceName}:`, error); + } + } + + public async set(instanceName: string, data: any): Promise { + try { + const rabbitmqData = data.rabbitmq; + + await this.prismaRepository.instance.update({ + where: { name: instanceName }, + data: { + rabbitmq: rabbitmqData, + }, + }); + + this.logger.log(`RabbitMQ configuration set for instance ${instanceName}`); + return { rabbitmq: rabbitmqData }; + } catch (error) { + this.logger.error(`Failed to set RabbitMQ config for ${instanceName}:`, error); + throw error; + } + } +} +``` + +### SQS Controller Implementation +```typescript +import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs'; + +export class SqsController extends EventController { + private sqsClient: SQSClient | null = null; + + constructor( + prismaRepository: PrismaRepository, + configService: ConfigService, + ) { + super(prismaRepository, configService, 'SqsController'); + this.initializeSQSClient(); + } + + private initializeSQSClient(): void { + try { + const sqsConfig = this.configService.get('SQS'); + if (!sqsConfig?.ENABLED) { + return; + } + + this.sqsClient = new SQSClient({ + region: sqsConfig.REGION, + credentials: { + accessKeyId: sqsConfig.ACCESS_KEY_ID, + secretAccessKey: sqsConfig.SECRET_ACCESS_KEY, + }, + }); + + this.logger.log('SQS client initialized'); + } catch (error) { + this.logger.error('Failed to initialize SQS client:', error); + } + } + + public async emit(data: EmitData): Promise { + if (!this.sqsClient) { + return; + } + + try { + const instance = await this.getInstanceConfig(data.instanceName); + if (!instance?.sqs?.enabled) { + return; + } + + const sqsConfig = instance.sqs; + + if (!this.shouldProcessEvent(data.event, sqsConfig.events)) { + return; + } + + const payload = { + event: data.event, + instance: data.instanceName, + data: data.data, + timestamp: data.dateTime, + sender: data.sender, + }; + + const command = new SendMessageCommand({ + QueueUrl: sqsConfig.queueUrl, + MessageBody: JSON.stringify(payload), + MessageAttributes: { + event: { + DataType: 'String', + StringValue: data.event, + }, + instance: { + DataType: 'String', + StringValue: data.instanceName, + }, + }, + MessageGroupId: data.instanceName, // For FIFO queues + MessageDeduplicationId: `${data.instanceName}-${Date.now()}`, // For FIFO queues + }); + + await this.sqsClient.send(command); + + this.logger.log(`SQS message sent for event ${data.event} to instance ${data.instanceName}`); + } catch (error) { + this.logger.error(`SQS emission failed for ${data.instanceName}:`, error); + } + } + + public async set(instanceName: string, data: any): Promise { + try { + const sqsData = data.sqs; + + await this.prismaRepository.instance.update({ + where: { name: instanceName }, + data: { + sqs: sqsData, + }, + }); + + this.logger.log(`SQS configuration set for instance ${instanceName}`); + return { sqs: sqsData }; + } catch (error) { + this.logger.error(`Failed to set SQS config for ${instanceName}:`, error); + throw error; + } + } +} +``` + +## Event DTO Pattern + +### Event Configuration DTO +```typescript +import { JsonValue } from '@prisma/client/runtime/library'; + +export class EventDto { + webhook?: { + enabled?: boolean; + events?: string[]; + url?: string; + headers?: JsonValue; + byEvents?: boolean; + base64?: boolean; + }; + + websocket?: { + enabled?: boolean; + events?: string[]; + }; + + sqs?: { + enabled?: boolean; + events?: string[]; + queueUrl?: string; + }; + + rabbitmq?: { + enabled?: boolean; + events?: string[]; + exchange?: string; + }; + + nats?: { + enabled?: boolean; + events?: string[]; + subject?: string; + }; + + pusher?: { + enabled?: boolean; + appId?: string; + key?: string; + secret?: string; + cluster?: string; + useTLS?: boolean; + events?: string[]; + }; +} +``` + +## Event Router Pattern + +### Event Router Structure +```typescript +import { NatsRouter } from '@api/integrations/event/nats/nats.router'; +import { PusherRouter } from '@api/integrations/event/pusher/pusher.router'; +import { RabbitmqRouter } from '@api/integrations/event/rabbitmq/rabbitmq.router'; +import { SqsRouter } from '@api/integrations/event/sqs/sqs.router'; +import { WebhookRouter } from '@api/integrations/event/webhook/webhook.router'; +import { WebsocketRouter } from '@api/integrations/event/websocket/websocket.router'; +import { Router } from 'express'; + +export class EventRouter { + public readonly router: Router; + + constructor(configService: any, ...guards: any[]) { + this.router = Router(); + + this.router.use('/webhook', new WebhookRouter(configService, ...guards).router); + this.router.use('/websocket', new WebsocketRouter(...guards).router); + this.router.use('/rabbitmq', new RabbitmqRouter(...guards).router); + this.router.use('/nats', new NatsRouter(...guards).router); + this.router.use('/pusher', new PusherRouter(...guards).router); + this.router.use('/sqs', new SqsRouter(...guards).router); + } +} +``` + +## Event Validation Schema + +### Event Configuration Validation +```typescript +import Joi from 'joi'; +import { EventController } from '@api/integrations/event/event.controller'; + +const eventListSchema = Joi.array().items( + Joi.string().valid(...EventController.events) +).optional(); + +export const webhookSchema = Joi.object({ + enabled: Joi.boolean().required(), + url: Joi.string().when('enabled', { + is: true, + then: Joi.required().uri({ scheme: ['http', 'https'] }), + otherwise: Joi.optional(), + }), + events: eventListSchema, + headers: Joi.object().pattern(Joi.string(), Joi.string()).optional(), + byEvents: Joi.boolean().optional().default(false), + base64: Joi.boolean().optional().default(false), +}).required(); + +export const websocketSchema = Joi.object({ + enabled: Joi.boolean().required(), + events: eventListSchema, +}).required(); + +export const rabbitmqSchema = Joi.object({ + enabled: Joi.boolean().required(), + events: eventListSchema, + exchange: Joi.string().optional().default('evolution-events'), +}).required(); + +export const sqsSchema = Joi.object({ + enabled: Joi.boolean().required(), + events: eventListSchema, + queueUrl: Joi.string().when('enabled', { + is: true, + then: Joi.required().uri(), + otherwise: Joi.optional(), + }), +}).required(); + +export const eventSchema = Joi.object({ + webhook: webhookSchema.optional(), + websocket: websocketSchema.optional(), + rabbitmq: rabbitmqSchema.optional(), + sqs: sqsSchema.optional(), + nats: Joi.object({ + enabled: Joi.boolean().required(), + events: eventListSchema, + subject: Joi.string().optional().default('evolution.events'), + }).optional(), + pusher: Joi.object({ + enabled: Joi.boolean().required(), + appId: Joi.string().when('enabled', { is: true, then: Joi.required() }), + key: Joi.string().when('enabled', { is: true, then: Joi.required() }), + secret: Joi.string().when('enabled', { is: true, then: Joi.required() }), + cluster: Joi.string().when('enabled', { is: true, then: Joi.required() }), + useTLS: Joi.boolean().optional().default(true), + events: eventListSchema, + }).optional(), +}).min(1).required(); +``` + +## Event Testing Pattern + +### Event Controller Testing +```typescript +describe('WebhookController', () => { + let controller: WebhookController; + let prismaRepository: jest.Mocked; + let configService: jest.Mocked; + + beforeEach(() => { + controller = new WebhookController(prismaRepository, configService); + }); + + describe('emit', () => { + it('should send webhook when enabled', async () => { + const mockInstance = { + webhook: { + enabled: true, + url: 'https://example.com/webhook', + events: ['MESSAGES_UPSERT'], + }, + }; + + prismaRepository.instance.findUnique.mockResolvedValue(mockInstance); + jest.spyOn(axios, 'post').mockResolvedValue({ status: 200 }); + + const eventData = { + instanceName: 'test-instance', + event: 'MESSAGES_UPSERT', + data: { message: 'test' }, + origin: 'test', + serverUrl: 'http://localhost', + dateTime: new Date().toISOString(), + sender: 'test', + }; + + await controller.emit(eventData); + + expect(axios.post).toHaveBeenCalledWith( + 'https://example.com/webhook', + expect.objectContaining({ + event: 'MESSAGES_UPSERT', + instance: 'test-instance', + }), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }) + ); + }); + }); +}); +``` \ No newline at end of file diff --git a/.cursor/rules/specialized-rules/integration-storage-rules.mdc b/.cursor/rules/specialized-rules/integration-storage-rules.mdc new file mode 100644 index 00000000..6456f5a4 --- /dev/null +++ b/.cursor/rules/specialized-rules/integration-storage-rules.mdc @@ -0,0 +1,608 @@ +--- +description: Storage integration patterns for Evolution API +globs: + - "src/api/integrations/storage/**/*.ts" +alwaysApply: false +--- + +# Evolution API Storage Integration Rules + +## Storage Service Pattern + +### Base Storage Service Structure +```typescript +import { InstanceDto } from '@api/dto/instance.dto'; +import { PrismaRepository } from '@api/repository/repository.service'; +import { Logger } from '@config/logger.config'; +import { BadRequestException } from '@exceptions'; + +export class StorageService { + constructor(private readonly prismaRepository: PrismaRepository) {} + + private readonly logger = new Logger('StorageService'); + + public async getMedia(instance: InstanceDto, query?: MediaDto) { + try { + const where: any = { + instanceId: instance.instanceId, + ...query, + }; + + const media = await this.prismaRepository.media.findMany({ + where, + select: { + id: true, + fileName: true, + type: true, + mimetype: true, + createdAt: true, + Message: true, + }, + }); + + if (!media || media.length === 0) { + throw 'Media not found'; + } + + return media; + } catch (error) { + throw new BadRequestException(error); + } + } + + public async getMediaUrl(instance: InstanceDto, data: MediaDto) { + const media = (await this.getMedia(instance, { id: data.id }))[0]; + const mediaUrl = await this.generateUrl(media.fileName, data.expiry); + return { + mediaUrl, + ...media, + }; + } + + protected abstract generateUrl(fileName: string, expiry?: number): Promise; +} +``` + +## S3/MinIO Integration Pattern + +### MinIO Client Setup +```typescript +import { ConfigService, S3 } from '@config/env.config'; +import { Logger } from '@config/logger.config'; +import { BadRequestException } from '@exceptions'; +import * as MinIo from 'minio'; +import { join } from 'path'; +import { Readable, Transform } from 'stream'; + +const logger = new Logger('S3 Service'); +const BUCKET = new ConfigService().get('S3'); + +interface Metadata extends MinIo.ItemBucketMetadata { + instanceId: string; + messageId?: string; +} + +const minioClient = (() => { + if (BUCKET?.ENABLE) { + return new MinIo.Client({ + endPoint: BUCKET.ENDPOINT, + port: BUCKET.PORT, + useSSL: BUCKET.USE_SSL, + accessKey: BUCKET.ACCESS_KEY, + secretKey: BUCKET.SECRET_KEY, + region: BUCKET.REGION, + }); + } +})(); + +const bucketName = process.env.S3_BUCKET; +``` + +### Bucket Management Functions +```typescript +const bucketExists = async (): Promise => { + if (minioClient) { + try { + const list = await minioClient.listBuckets(); + return !!list.find((bucket) => bucket.name === bucketName); + } catch (error) { + logger.error('Error checking bucket existence:', error); + return false; + } + } + return false; +}; + +const setBucketPolicy = async (): Promise => { + if (minioClient && bucketName) { + try { + const policy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { AWS: ['*'] }, + Action: ['s3:GetObject'], + Resource: [`arn:aws:s3:::${bucketName}/*`], + }, + ], + }; + + await minioClient.setBucketPolicy(bucketName, JSON.stringify(policy)); + logger.log('Bucket policy set successfully'); + } catch (error) { + logger.error('Error setting bucket policy:', error); + } + } +}; + +const createBucket = async (): Promise => { + if (minioClient && bucketName) { + try { + const exists = await bucketExists(); + if (!exists) { + await minioClient.makeBucket(bucketName, BUCKET.REGION || 'us-east-1'); + await setBucketPolicy(); + logger.log(`Bucket ${bucketName} created successfully`); + } + } catch (error) { + logger.error('Error creating bucket:', error); + } + } +}; +``` + +### File Upload Functions +```typescript +export const uploadFile = async ( + fileName: string, + buffer: Buffer, + mimetype: string, + metadata?: Metadata, +): Promise => { + if (!minioClient || !bucketName) { + throw new BadRequestException('S3 storage not configured'); + } + + try { + await createBucket(); + + const uploadMetadata = { + 'Content-Type': mimetype, + ...metadata, + }; + + await minioClient.putObject(bucketName, fileName, buffer, buffer.length, uploadMetadata); + + logger.log(`File ${fileName} uploaded successfully`); + return fileName; + } catch (error) { + logger.error(`Error uploading file ${fileName}:`, error); + throw new BadRequestException(`Failed to upload file: ${error.message}`); + } +}; + +export const uploadStream = async ( + fileName: string, + stream: Readable, + size: number, + mimetype: string, + metadata?: Metadata, +): Promise => { + if (!minioClient || !bucketName) { + throw new BadRequestException('S3 storage not configured'); + } + + try { + await createBucket(); + + const uploadMetadata = { + 'Content-Type': mimetype, + ...metadata, + }; + + await minioClient.putObject(bucketName, fileName, stream, size, uploadMetadata); + + logger.log(`Stream ${fileName} uploaded successfully`); + return fileName; + } catch (error) { + logger.error(`Error uploading stream ${fileName}:`, error); + throw new BadRequestException(`Failed to upload stream: ${error.message}`); + } +}; +``` + +### File Download Functions +```typescript +export const getObject = async (fileName: string): Promise => { + if (!minioClient || !bucketName) { + throw new BadRequestException('S3 storage not configured'); + } + + try { + const stream = await minioClient.getObject(bucketName, fileName); + const chunks: Buffer[] = []; + + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + }); + } catch (error) { + logger.error(`Error getting object ${fileName}:`, error); + throw new BadRequestException(`Failed to get object: ${error.message}`); + } +}; + +export const getObjectUrl = async (fileName: string, expiry: number = 3600): Promise => { + if (!minioClient || !bucketName) { + throw new BadRequestException('S3 storage not configured'); + } + + try { + const url = await minioClient.presignedGetObject(bucketName, fileName, expiry); + logger.log(`Generated URL for ${fileName} with expiry ${expiry}s`); + return url; + } catch (error) { + logger.error(`Error generating URL for ${fileName}:`, error); + throw new BadRequestException(`Failed to generate URL: ${error.message}`); + } +}; + +export const getObjectStream = async (fileName: string): Promise => { + if (!minioClient || !bucketName) { + throw new BadRequestException('S3 storage not configured'); + } + + try { + const stream = await minioClient.getObject(bucketName, fileName); + return stream; + } catch (error) { + logger.error(`Error getting object stream ${fileName}:`, error); + throw new BadRequestException(`Failed to get object stream: ${error.message}`); + } +}; +``` + +### File Management Functions +```typescript +export const deleteObject = async (fileName: string): Promise => { + if (!minioClient || !bucketName) { + throw new BadRequestException('S3 storage not configured'); + } + + try { + await minioClient.removeObject(bucketName, fileName); + logger.log(`File ${fileName} deleted successfully`); + } catch (error) { + logger.error(`Error deleting file ${fileName}:`, error); + throw new BadRequestException(`Failed to delete file: ${error.message}`); + } +}; + +export const listObjects = async (prefix?: string): Promise => { + if (!minioClient || !bucketName) { + throw new BadRequestException('S3 storage not configured'); + } + + try { + const objects: MinIo.BucketItem[] = []; + const stream = minioClient.listObjects(bucketName, prefix, true); + + return new Promise((resolve, reject) => { + stream.on('data', (obj) => objects.push(obj)); + stream.on('end', () => resolve(objects)); + stream.on('error', reject); + }); + } catch (error) { + logger.error('Error listing objects:', error); + throw new BadRequestException(`Failed to list objects: ${error.message}`); + } +}; + +export const objectExists = async (fileName: string): Promise => { + if (!minioClient || !bucketName) { + return false; + } + + try { + await minioClient.statObject(bucketName, fileName); + return true; + } catch (error) { + return false; + } +}; +``` + +## Storage Controller Pattern + +### S3 Controller Implementation +```typescript +import { InstanceDto } from '@api/dto/instance.dto'; +import { MediaDto } from '@api/integrations/storage/s3/dto/media.dto'; +import { S3Service } from '@api/integrations/storage/s3/services/s3.service'; + +export class S3Controller { + constructor(private readonly s3Service: S3Service) {} + + public async getMedia(instance: InstanceDto, data: MediaDto) { + return this.s3Service.getMedia(instance, data); + } + + public async getMediaUrl(instance: InstanceDto, data: MediaDto) { + return this.s3Service.getMediaUrl(instance, data); + } + + public async uploadMedia(instance: InstanceDto, data: UploadMediaDto) { + return this.s3Service.uploadMedia(instance, data); + } + + public async deleteMedia(instance: InstanceDto, data: MediaDto) { + return this.s3Service.deleteMedia(instance, data); + } +} +``` + +## Storage Router Pattern + +### Storage Router Structure +```typescript +import { S3Router } from '@api/integrations/storage/s3/routes/s3.router'; +import { Router } from 'express'; + +export class StorageRouter { + public readonly router: Router; + + constructor(...guards: any[]) { + this.router = Router(); + + this.router.use('/s3', new S3Router(...guards).router); + // Add other storage providers here + // this.router.use('/gcs', new GCSRouter(...guards).router); + // this.router.use('/azure', new AzureRouter(...guards).router); + } +} +``` + +### S3 Specific Router +```typescript +import { RouterBroker } from '@api/abstract/abstract.router'; +import { MediaDto } from '@api/integrations/storage/s3/dto/media.dto'; +import { s3Schema, s3UrlSchema } from '@api/integrations/storage/s3/validate/s3.schema'; +import { HttpStatus } from '@api/routes/index.router'; +import { s3Controller } from '@api/server.module'; +import { RequestHandler, Router } from 'express'; + +export class S3Router extends RouterBroker { + constructor(...guards: RequestHandler[]) { + super(); + this.router + .post(this.routerPath('getMedia'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: s3Schema, + ClassRef: MediaDto, + execute: (instance, data) => s3Controller.getMedia(instance, data), + }); + + res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('getMediaUrl'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: s3UrlSchema, + ClassRef: MediaDto, + execute: (instance, data) => s3Controller.getMediaUrl(instance, data), + }); + + res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('uploadMedia'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: uploadSchema, + ClassRef: UploadMediaDto, + execute: (instance, data) => s3Controller.uploadMedia(instance, data), + }); + + res.status(HttpStatus.CREATED).json(response); + }) + .delete(this.routerPath('deleteMedia'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: s3Schema, + ClassRef: MediaDto, + execute: (instance, data) => s3Controller.deleteMedia(instance, data), + }); + + res.status(HttpStatus.OK).json(response); + }); + } + + public readonly router: Router = Router(); +} +``` + +## Storage DTO Pattern + +### Media DTO +```typescript +export class MediaDto { + id?: string; + fileName?: string; + type?: string; + mimetype?: string; + expiry?: number; +} + +export class UploadMediaDto { + fileName: string; + mimetype: string; + buffer?: Buffer; + base64?: string; + url?: string; + metadata?: { + instanceId: string; + messageId?: string; + contactId?: string; + [key: string]: any; + }; +} +``` + +## Storage Validation Schema + +### S3 Validation Schemas +```typescript +import Joi from 'joi'; + +export const s3Schema = Joi.object({ + id: Joi.string().optional(), + fileName: Joi.string().optional(), + type: Joi.string().optional().valid('image', 'video', 'audio', 'document'), + mimetype: Joi.string().optional(), + expiry: Joi.number().optional().min(60).max(604800).default(3600), // 1 min to 7 days +}).min(1).required(); + +export const s3UrlSchema = Joi.object({ + id: Joi.string().required(), + expiry: Joi.number().optional().min(60).max(604800).default(3600), +}).required(); + +export const uploadSchema = Joi.object({ + fileName: Joi.string().required().max(255), + mimetype: Joi.string().required(), + buffer: Joi.binary().optional(), + base64: Joi.string().base64().optional(), + url: Joi.string().uri().optional(), + metadata: Joi.object({ + instanceId: Joi.string().required(), + messageId: Joi.string().optional(), + contactId: Joi.string().optional(), + }).optional(), +}).xor('buffer', 'base64', 'url').required(); // Exactly one of these must be present +``` + +## Error Handling in Storage + +### Storage-Specific Error Handling +```typescript +// CORRECT - Storage-specific error handling +public async uploadFile(fileName: string, buffer: Buffer): Promise { + try { + const result = await this.storageClient.upload(fileName, buffer); + return result; + } catch (error) { + this.logger.error(`Storage upload failed: ${error.message}`); + + if (error.code === 'NoSuchBucket') { + throw new BadRequestException('Storage bucket not found'); + } + + if (error.code === 'AccessDenied') { + throw new UnauthorizedException('Storage access denied'); + } + + if (error.code === 'EntityTooLarge') { + throw new BadRequestException('File too large'); + } + + throw new InternalServerErrorException('Storage operation failed'); + } +} +``` + +## Storage Configuration Pattern + +### Environment Configuration +```typescript +export interface S3Config { + ENABLE: boolean; + ENDPOINT: string; + PORT: number; + USE_SSL: boolean; + ACCESS_KEY: string; + SECRET_KEY: string; + REGION: string; + BUCKET: string; +} + +// Usage in service +const s3Config = this.configService.get('S3'); +if (!s3Config.ENABLE) { + throw new BadRequestException('S3 storage is disabled'); +} +``` + +## Storage Testing Pattern + +### Storage Service Testing +```typescript +describe('S3Service', () => { + let service: S3Service; + let prismaRepository: jest.Mocked; + + beforeEach(() => { + service = new S3Service(prismaRepository); + }); + + describe('getMedia', () => { + it('should return media list', async () => { + const instance = { instanceId: 'test-instance' }; + const mockMedia = [ + { id: '1', fileName: 'test.jpg', type: 'image', mimetype: 'image/jpeg' }, + ]; + + prismaRepository.media.findMany.mockResolvedValue(mockMedia); + + const result = await service.getMedia(instance); + + expect(result).toEqual(mockMedia); + expect(prismaRepository.media.findMany).toHaveBeenCalledWith({ + where: { instanceId: 'test-instance' }, + select: expect.objectContaining({ + id: true, + fileName: true, + type: true, + mimetype: true, + }), + }); + }); + + it('should throw error when no media found', async () => { + const instance = { instanceId: 'test-instance' }; + prismaRepository.media.findMany.mockResolvedValue([]); + + await expect(service.getMedia(instance)).rejects.toThrow(BadRequestException); + }); + }); +}); +``` + +## Storage Performance Considerations + +### Efficient File Handling +```typescript +// CORRECT - Stream-based upload for large files +public async uploadLargeFile(fileName: string, stream: Readable, size: number): Promise { + const uploadStream = new Transform({ + transform(chunk, encoding, callback) { + // Optional: Add compression, encryption, etc. + callback(null, chunk); + }, + }); + + return new Promise((resolve, reject) => { + stream + .pipe(uploadStream) + .on('error', reject) + .on('finish', () => resolve(fileName)); + }); +} + +// INCORRECT - Loading entire file into memory +public async uploadLargeFile(fileName: string, filePath: string): Promise { + const buffer = fs.readFileSync(filePath); // ❌ Memory intensive for large files + return await this.uploadFile(fileName, buffer); +} +``` \ No newline at end of file diff --git a/.cursor/rules/specialized-rules/route-rules.mdc b/.cursor/rules/specialized-rules/route-rules.mdc new file mode 100644 index 00000000..7be40849 --- /dev/null +++ b/.cursor/rules/specialized-rules/route-rules.mdc @@ -0,0 +1,416 @@ +--- +description: Router patterns for Evolution API +globs: + - "src/api/routes/**/*.ts" +alwaysApply: false +--- + +# Evolution API Route Rules + +## Router Base Pattern + +### RouterBroker Extension +```typescript +import { RouterBroker } from '@api/abstract/abstract.router'; +import { RequestHandler, Router } from 'express'; +import { HttpStatus } from './index.router'; + +export class ExampleRouter extends RouterBroker { + constructor(...guards: RequestHandler[]) { + super(); + this.router + .get(this.routerPath('findExample'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: null, + ClassRef: ExampleDto, + execute: (instance) => exampleController.find(instance), + }); + + return res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('createExample'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: exampleSchema, + ClassRef: ExampleDto, + execute: (instance, data) => exampleController.create(instance, data), + }); + + return res.status(HttpStatus.CREATED).json(response); + }); + } + + public readonly router: Router = Router(); +} +``` + +## Main Router Pattern + +### Index Router Structure +```typescript +import { Router } from 'express'; +import { authGuard } from '@api/guards/auth.guard'; +import { instanceExistsGuard, instanceLoggedGuard } from '@api/guards/instance.guard'; +import Telemetry from '@api/guards/telemetry.guard'; + +enum HttpStatus { + OK = 200, + CREATED = 201, + NOT_FOUND = 404, + FORBIDDEN = 403, + BAD_REQUEST = 400, + UNAUTHORIZED = 401, + INTERNAL_SERVER_ERROR = 500, +} + +const router: Router = Router(); +const guards = [instanceExistsGuard, instanceLoggedGuard, authGuard['apikey']]; +const telemetry = new Telemetry(); + +router + .use((req, res, next) => telemetry.collectTelemetry(req, res, next)) + .get('/', async (req, res) => { + res.status(HttpStatus.OK).json({ + status: HttpStatus.OK, + message: 'Welcome to the Evolution API, it is working!', + version: packageJson.version, + clientName: process.env.DATABASE_CONNECTION_CLIENT_NAME, + manager: !serverConfig.DISABLE_MANAGER ? `${req.protocol}://${req.get('host')}/manager` : undefined, + documentation: `https://doc.evolution-api.com`, + whatsappWebVersion: (await fetchLatestWaWebVersion({})).version.join('.'), + }); + }) + .use('/instance', new InstanceRouter(configService, ...guards).router) + .use('/message', new MessageRouter(...guards).router) + .use('/chat', new ChatRouter(...guards).router) + .use('/business', new BusinessRouter(...guards).router); + +export { HttpStatus, router }; +``` + +## Data Validation Pattern + +### RouterBroker dataValidate Usage +```typescript +// CORRECT - Standard validation pattern +.post(this.routerPath('createTemplate'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: templateSchema, + ClassRef: TemplateDto, + execute: (instance, data) => templateController.create(instance, data), + }); + + return res.status(HttpStatus.CREATED).json(response); +}) + +// CORRECT - No schema validation (for simple DTOs) +.get(this.routerPath('findTemplate'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: null, + ClassRef: InstanceDto, + execute: (instance) => templateController.find(instance), + }); + + return res.status(HttpStatus.OK).json(response); +}) +``` + +## Error Handling in Routes + +### Try-Catch Pattern +```typescript +// CORRECT - Error handling with utility function +.post(this.routerPath('getCatalog'), ...guards, async (req, res) => { + try { + const response = await this.dataValidate({ + request: req, + schema: catalogSchema, + ClassRef: NumberDto, + execute: (instance, data) => businessController.fetchCatalog(instance, data), + }); + + return res.status(HttpStatus.OK).json(response); + } catch (error) { + // Log error for debugging + console.error('Business catalog error:', error); + + // Use utility function to create standardized error response + const errorResponse = createMetaErrorResponse(error, 'business_catalog'); + return res.status(errorResponse.status).json(errorResponse); + } +}) + +// INCORRECT - Let RouterBroker handle errors (when possible) +.post(this.routerPath('simpleOperation'), ...guards, async (req, res) => { + try { + const response = await this.dataValidate({ + request: req, + schema: simpleSchema, + ClassRef: SimpleDto, + execute: (instance, data) => controller.simpleOperation(instance, data), + }); + + return res.status(HttpStatus.OK).json(response); + } catch (error) { + throw error; // ❌ Unnecessary - RouterBroker handles this + } +}) +``` + +## Route Path Pattern + +### routerPath Usage +```typescript +// CORRECT - Use routerPath for consistent naming +.get(this.routerPath('findLabels'), ...guards, async (req, res) => { + // Implementation +}) +.post(this.routerPath('handleLabel'), ...guards, async (req, res) => { + // Implementation +}) + +// INCORRECT - Hardcoded paths +.get('/labels', ...guards, async (req, res) => { // ❌ Use routerPath + // Implementation +}) +``` + +## Guard Application Pattern + +### Guards Usage +```typescript +// CORRECT - Apply guards to protected routes +export class ProtectedRouter extends RouterBroker { + constructor(...guards: RequestHandler[]) { + super(); + this.router + .get(this.routerPath('protectedAction'), ...guards, async (req, res) => { + // Protected action + }) + .post(this.routerPath('anotherAction'), ...guards, async (req, res) => { + // Another protected action + }); + } +} + +// CORRECT - No guards for public routes +export class PublicRouter extends RouterBroker { + constructor() { + super(); + this.router + .get('/health', async (req, res) => { + res.status(HttpStatus.OK).json({ status: 'healthy' }); + }) + .get('/version', async (req, res) => { + res.status(HttpStatus.OK).json({ version: packageJson.version }); + }); + } +} +``` + +## Static File Serving Pattern + +### Static Assets Route +```typescript +// CORRECT - Secure static file serving +router.get('/assets/*', (req, res) => { + const fileName = req.params[0]; + + // Security: Reject paths containing traversal patterns + if (!fileName || fileName.includes('..') || fileName.includes('\\') || path.isAbsolute(fileName)) { + return res.status(403).send('Forbidden'); + } + + const basePath = path.join(process.cwd(), 'manager', 'dist'); + const assetsPath = path.join(basePath, 'assets'); + const filePath = path.join(assetsPath, fileName); + + // Security: Ensure the resolved path is within the assets directory + const resolvedPath = path.resolve(filePath); + const resolvedAssetsPath = path.resolve(assetsPath); + + if (!resolvedPath.startsWith(resolvedAssetsPath + path.sep) && resolvedPath !== resolvedAssetsPath) { + return res.status(403).send('Forbidden'); + } + + if (fs.existsSync(resolvedPath)) { + res.set('Content-Type', mimeTypes.lookup(resolvedPath) || 'text/css'); + res.send(fs.readFileSync(resolvedPath)); + } else { + res.status(404).send('File not found'); + } +}); +``` + +## Special Route Patterns + +### Manager Route Pattern +```typescript +export class ViewsRouter extends RouterBroker { + public readonly router: Router; + + constructor() { + super(); + this.router = Router(); + + const basePath = path.join(process.cwd(), 'manager', 'dist'); + const indexPath = path.join(basePath, 'index.html'); + + this.router.use(express.static(basePath)); + + this.router.get('*', (req, res) => { + res.sendFile(indexPath); + }); + } +} +``` + +### Webhook Route Pattern +```typescript +// CORRECT - Webhook without guards +.post('/webhook/evolution', async (req, res) => { + const response = await evolutionController.receiveWebhook(req.body); + return res.status(HttpStatus.OK).json(response); +}) + +// CORRECT - Webhook with signature validation +.post('/webhook/meta', validateWebhookSignature, async (req, res) => { + const response = await metaController.receiveWebhook(req.body); + return res.status(HttpStatus.OK).json(response); +}) +``` + +## Response Pattern + +### Standard Response Format +```typescript +// CORRECT - Standard success response +return res.status(HttpStatus.OK).json(response); + +// CORRECT - Created response +return res.status(HttpStatus.CREATED).json(response); + +// CORRECT - Custom response with additional data +return res.status(HttpStatus.OK).json({ + ...response, + timestamp: new Date().toISOString(), + instanceName: req.params.instanceName, +}); +``` + +## Route Organization + +### File Structure +``` +src/api/routes/ +├── index.router.ts # Main router with all route registrations +├── instance.router.ts # Instance management routes +├── sendMessage.router.ts # Message sending routes +├── chat.router.ts # Chat operations routes +├── business.router.ts # Business API routes +├── group.router.ts # Group management routes +├── label.router.ts # Label management routes +├── proxy.router.ts # Proxy configuration routes +├── settings.router.ts # Instance settings routes +├── template.router.ts # Template management routes +├── call.router.ts # Call operations routes +└── view.router.ts # Frontend views routes +``` + +## Route Testing Pattern + +### Router Testing +```typescript +describe('ExampleRouter', () => { + let app: express.Application; + let router: ExampleRouter; + + beforeEach(() => { + app = express(); + router = new ExampleRouter(); + app.use('/api', router.router); + app.use(express.json()); + }); + + describe('GET /findExample', () => { + it('should return example data', async () => { + const response = await request(app) + .get('/api/findExample/test-instance') + .set('apikey', 'test-key') + .expect(200); + + expect(response.body).toBeDefined(); + expect(response.body.instanceName).toBe('test-instance'); + }); + + it('should return 401 without API key', async () => { + await request(app) + .get('/api/findExample/test-instance') + .expect(401); + }); + }); + + describe('POST /createExample', () => { + it('should create example successfully', async () => { + const data = { + name: 'Test Example', + description: 'Test Description', + }; + + const response = await request(app) + .post('/api/createExample/test-instance') + .set('apikey', 'test-key') + .send(data) + .expect(201); + + expect(response.body.name).toBe(data.name); + }); + + it('should validate required fields', async () => { + const data = { + description: 'Test Description', + // Missing required 'name' field + }; + + await request(app) + .post('/api/createExample/test-instance') + .set('apikey', 'test-key') + .send(data) + .expect(400); + }); + }); +}); +``` + +## Route Documentation + +### JSDoc for Routes +```typescript +/** + * @route GET /api/template/findTemplate/:instanceName + * @description Find template for instance + * @param {string} instanceName - Instance name + * @returns {TemplateDto} Template data + * @throws {404} Template not found + * @throws {401} Unauthorized + */ +.get(this.routerPath('findTemplate'), ...guards, async (req, res) => { + // Implementation +}) + +/** + * @route POST /api/template/createTemplate/:instanceName + * @description Create new template + * @param {string} instanceName - Instance name + * @body {TemplateDto} Template data + * @returns {TemplateDto} Created template + * @throws {400} Validation error + * @throws {401} Unauthorized + */ +.post(this.routerPath('createTemplate'), ...guards, async (req, res) => { + // Implementation +}) +``` \ No newline at end of file diff --git a/.cursor/rules/specialized-rules/service-rules.mdc b/.cursor/rules/specialized-rules/service-rules.mdc new file mode 100644 index 00000000..0f516c55 --- /dev/null +++ b/.cursor/rules/specialized-rules/service-rules.mdc @@ -0,0 +1,294 @@ +--- +description: Service layer patterns for Evolution API +globs: + - "src/api/services/**/*.ts" + - "src/api/integrations/**/services/*.ts" +alwaysApply: false +--- + +# Evolution API Service Rules + +## Service Structure Pattern + +### Standard Service Class +```typescript +export class ExampleService { + constructor(private readonly waMonitor: WAMonitoringService) {} + + private readonly logger = new Logger('ExampleService'); + + public async create(instance: InstanceDto, data: ExampleDto) { + await this.waMonitor.waInstances[instance.instanceName].setData(data); + return { example: { ...instance, data } }; + } + + public async find(instance: InstanceDto): Promise { + try { + const result = await this.waMonitor.waInstances[instance.instanceName].findData(); + + if (Object.keys(result).length === 0) { + throw new Error('Data not found'); + } + + return result; + } catch (error) { + return null; // Evolution pattern - return null on error + } + } +} +``` + +## Dependency Injection Pattern + +### Constructor Pattern +```typescript +// CORRECT - Evolution API pattern +constructor( + private readonly waMonitor: WAMonitoringService, + private readonly prismaRepository: PrismaRepository, + private readonly configService: ConfigService, +) {} + +// INCORRECT - Don't use +constructor(waMonitor, prismaRepository, configService) {} // ❌ No types +``` + +## Logger Pattern + +### Standard Logger Usage +```typescript +// CORRECT - Evolution API pattern +private readonly logger = new Logger('ServiceName'); + +// Usage +this.logger.log('Operation started'); +this.logger.error('Operation failed', error); + +// INCORRECT +console.log('Operation started'); // ❌ Use Logger +``` + +## WAMonitor Integration Pattern + +### Instance Access Pattern +```typescript +// CORRECT - Standard pattern +public async operation(instance: InstanceDto, data: DataDto) { + await this.waMonitor.waInstances[instance.instanceName].performAction(data); + return { result: { ...instance, data } }; +} + +// Instance validation +const waInstance = this.waMonitor.waInstances[instance.instanceName]; +if (!waInstance) { + throw new NotFoundException('Instance not found'); +} +``` + +## Error Handling Pattern + +### Try-Catch Pattern +```typescript +// CORRECT - Evolution API pattern +public async find(instance: InstanceDto): Promise { + try { + const result = await this.waMonitor.waInstances[instance.instanceName].findData(); + + if (Object.keys(result).length === 0) { + throw new Error('Data not found'); + } + + return result; + } catch (error) { + this.logger.error('Find operation failed', error); + return null; // Return null on error (Evolution pattern) + } +} +``` + +## Cache Integration Pattern + +### Cache Service Usage +```typescript +export class CacheAwareService { + constructor( + private readonly cache: CacheService, + private readonly chatwootCache: CacheService, + private readonly baileysCache: CacheService, + ) {} + + public async getCachedData(key: string): Promise { + const cached = await this.cache.get(key); + if (cached) return cached; + + const data = await this.fetchFromSource(key); + await this.cache.set(key, data, 300); // 5 min TTL + return data; + } +} +``` + +## Integration Service Patterns + +### Chatbot Service Base Pattern +```typescript +export class ChatbotService extends BaseChatbotService { + constructor( + waMonitor: WAMonitoringService, + prismaRepository: PrismaRepository, + configService: ConfigService, + ) { + super(waMonitor, prismaRepository, 'ChatbotService', configService); + } + + protected async processBot( + waInstance: any, + remoteJid: string, + bot: BotType, + session: any, + settings: any, + content: string, + ): Promise { + // Implementation + } +} +``` + +### Channel Service Pattern +```typescript +export class ChannelService extends ChannelStartupService { + constructor( + configService: ConfigService, + eventEmitter: EventEmitter2, + prismaRepository: PrismaRepository, + cache: CacheService, + chatwootCache: CacheService, + baileysCache: CacheService, + ) { + super(configService, eventEmitter, prismaRepository, cache, chatwootCache, baileysCache); + } + + public readonly logger = new Logger('ChannelService'); + public client: WASocket; + public readonly instance: wa.Instance = {}; +} +``` + +## Service Initialization Pattern + +### Service Registration +```typescript +// In server.module.ts pattern +export const templateService = new TemplateService( + waMonitor, + prismaRepository, + configService, +); + +export const settingsService = new SettingsService(waMonitor); +``` + +## Async Operation Patterns + +### Promise Handling +```typescript +// CORRECT - Evolution API pattern +public async sendMessage(instance: InstanceDto, data: MessageDto) { + const waInstance = this.waMonitor.waInstances[instance.instanceName]; + return await waInstance.sendMessage(data); +} + +// INCORRECT - Don't use .then() +public sendMessage(instance: InstanceDto, data: MessageDto) { + return this.waMonitor.waInstances[instance.instanceName] + .sendMessage(data) + .then(result => result); // ❌ Use async/await +} +``` + +## Configuration Access Pattern + +### Config Service Usage +```typescript +// CORRECT - Evolution API pattern +const serverConfig = this.configService.get('SERVER'); +const authConfig = this.configService.get('AUTHENTICATION'); +const dbConfig = this.configService.get('DATABASE'); + +// Type-safe configuration access +if (this.configService.get('CHATWOOT').ENABLED) { + // Chatwoot logic +} +``` + +## Event Emission Pattern + +### EventEmitter2 Usage +```typescript +// CORRECT - Evolution API pattern +this.eventEmitter.emit(Events.INSTANCE_CREATE, { + instanceName: instance.name, + status: 'created', +}); + +// Chatwoot event pattern +if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { + this.chatwootService.eventWhatsapp( + Events.STATUS_INSTANCE, + { instanceName: this.instance.name }, + { + instance: this.instance.name, + status: 'created', + }, + ); +} +``` + +## Service Method Naming + +### Standard Method Names +- `create()` - Create new resource +- `find()` - Find single resource +- `findAll()` - Find multiple resources +- `update()` - Update resource +- `delete()` - Delete resource +- `fetch*()` - Fetch from external API +- `send*()` - Send data/messages +- `process*()` - Process data + +## Service Testing Pattern + +### Unit Test Structure +```typescript +describe('ExampleService', () => { + let service: ExampleService; + let waMonitor: jest.Mocked; + let prismaRepository: jest.Mocked; + + beforeEach(() => { + const mockWaMonitor = { + waInstances: { + 'test-instance': { + performAction: jest.fn(), + }, + }, + }; + + service = new ExampleService( + mockWaMonitor as any, + prismaRepository, + configService, + ); + }); + + it('should perform action successfully', async () => { + const instance = { instanceName: 'test-instance' }; + const data = { test: 'data' }; + + const result = await service.create(instance, data); + + expect(result).toBeDefined(); + expect(waMonitor.waInstances['test-instance'].performAction).toHaveBeenCalledWith(data); + }); +}); +``` \ No newline at end of file diff --git a/.cursor/rules/specialized-rules/type-rules.mdc b/.cursor/rules/specialized-rules/type-rules.mdc new file mode 100644 index 00000000..cf740067 --- /dev/null +++ b/.cursor/rules/specialized-rules/type-rules.mdc @@ -0,0 +1,490 @@ +--- +description: Type definitions and interfaces for Evolution API +globs: + - "src/api/types/**/*.ts" + - "src/@types/**/*.ts" +alwaysApply: false +--- + +# Evolution API Type Rules + +## Namespace Pattern + +### WhatsApp Types Namespace +```typescript +/* eslint-disable @typescript-eslint/no-namespace */ +import { JsonValue } from '@prisma/client/runtime/library'; +import { AuthenticationState, WAConnectionState } from 'baileys'; + +export declare namespace wa { + export type QrCode = { + count?: number; + pairingCode?: string; + base64?: string; + code?: string; + }; + + export type Instance = { + id?: string; + qrcode?: QrCode; + pairingCode?: string; + authState?: { state: AuthenticationState; saveCreds: () => void }; + name?: string; + wuid?: string; + profileName?: string; + profilePictureUrl?: string; + token?: string; + number?: string; + integration?: string; + businessId?: string; + }; + + export type LocalChatwoot = { + enabled?: boolean; + accountId?: string; + token?: string; + url?: string; + nameInbox?: string; + mergeBrazilContacts?: boolean; + importContacts?: boolean; + importMessages?: boolean; + daysLimitImportMessages?: number; + organization?: string; + logo?: string; + }; + + export type LocalProxy = { + enabled?: boolean; + host?: string; + port?: string; + protocol?: string; + username?: string; + password?: string; + }; + + export type LocalSettings = { + rejectCall?: boolean; + msgCall?: string; + groupsIgnore?: boolean; + alwaysOnline?: boolean; + readMessages?: boolean; + readStatus?: boolean; + syncFullHistory?: boolean; + }; + + export type LocalWebHook = { + enabled?: boolean; + url?: string; + events?: string[]; + headers?: JsonValue; + byEvents?: boolean; + base64?: boolean; + }; + + export type StatusMessage = 'ERROR' | 'PENDING' | 'SERVER_ACK' | 'DELIVERY_ACK' | 'READ' | 'DELETED' | 'PLAYED'; +} +``` + +## Enum Definitions + +### Events Enum +```typescript +export enum Events { + APPLICATION_STARTUP = 'application.startup', + INSTANCE_CREATE = 'instance.create', + INSTANCE_DELETE = 'instance.delete', + QRCODE_UPDATED = 'qrcode.updated', + CONNECTION_UPDATE = 'connection.update', + STATUS_INSTANCE = 'status.instance', + MESSAGES_SET = 'messages.set', + MESSAGES_UPSERT = 'messages.upsert', + MESSAGES_EDITED = 'messages.edited', + MESSAGES_UPDATE = 'messages.update', + MESSAGES_DELETE = 'messages.delete', + SEND_MESSAGE = 'send.message', + SEND_MESSAGE_UPDATE = 'send.message.update', + CONTACTS_SET = 'contacts.set', + CONTACTS_UPSERT = 'contacts.upsert', + CONTACTS_UPDATE = 'contacts.update', + PRESENCE_UPDATE = 'presence.update', + CHATS_SET = 'chats.set', + CHATS_UPDATE = 'chats.update', + CHATS_UPSERT = 'chats.upsert', + CHATS_DELETE = 'chats.delete', + GROUPS_UPSERT = 'groups.upsert', + GROUPS_UPDATE = 'groups.update', + GROUP_PARTICIPANTS_UPDATE = 'group-participants.update', + CALL = 'call', + TYPEBOT_START = 'typebot.start', + TYPEBOT_CHANGE_STATUS = 'typebot.change-status', + LABELS_EDIT = 'labels.edit', + LABELS_ASSOCIATION = 'labels.association', + CREDS_UPDATE = 'creds.update', + MESSAGING_HISTORY_SET = 'messaging-history.set', + REMOVE_INSTANCE = 'remove.instance', + LOGOUT_INSTANCE = 'logout.instance', +} +``` + +### Integration Types +```typescript +export const Integration = { + WHATSAPP_BUSINESS: 'WHATSAPP-BUSINESS', + WHATSAPP_BAILEYS: 'WHATSAPP-BAILEYS', + EVOLUTION: 'EVOLUTION', +} as const; + +export type IntegrationType = typeof Integration[keyof typeof Integration]; +``` + +## Constant Arrays + +### Message Type Constants +```typescript +export const TypeMediaMessage = [ + 'imageMessage', + 'documentMessage', + 'audioMessage', + 'videoMessage', + 'stickerMessage', + 'ptvMessage', // Evolution API includes this +]; + +export const MessageSubtype = [ + 'ephemeralMessage', + 'documentWithCaptionMessage', + 'viewOnceMessage', + 'viewOnceMessageV2', +]; + +export type MediaMessageType = typeof TypeMediaMessage[number]; +export type MessageSubtypeType = typeof MessageSubtype[number]; +``` + +## Interface Definitions + +### Service Interfaces +```typescript +export interface ServiceInterface { + create(instance: InstanceDto, data: any): Promise; + find(instance: InstanceDto): Promise; + update?(instance: InstanceDto, data: any): Promise; + delete?(instance: InstanceDto): Promise; +} + +export interface ChannelServiceInterface extends ServiceInterface { + sendMessage(data: SendMessageDto): Promise; + connectToWhatsapp(data?: any): Promise; + receiveWebhook?(data: any): Promise; +} + +export interface ChatbotServiceInterface extends ServiceInterface { + processMessage( + instanceName: string, + remoteJid: string, + message: any, + pushName?: string, + ): Promise; +} +``` + +## Configuration Types + +### Environment Configuration Types +```typescript +export interface DatabaseConfig { + CONNECTION: { + URI: string; + DB_PREFIX_NAME: string; + CLIENT_NAME?: string; + }; + ENABLED: boolean; + SAVE_DATA: { + INSTANCE: boolean; + NEW_MESSAGE: boolean; + MESSAGE_UPDATE: boolean; + CONTACTS: boolean; + CHATS: boolean; + }; +} + +export interface AuthConfig { + TYPE: 'apikey' | 'jwt'; + API_KEY: { + KEY: string; + }; + JWT?: { + EXPIRIN_IN: number; + SECRET: string; + }; +} + +export interface CacheConfig { + REDIS: { + ENABLED: boolean; + URI: string; + PREFIX_KEY: string; + SAVE_INSTANCES: boolean; + }; + LOCAL: { + ENABLED: boolean; + TTL: number; + }; +} +``` + +## Message Types + +### Message Structure Types +```typescript +export interface MessageContent { + text?: string; + caption?: string; + media?: Buffer | string; + mediatype?: 'image' | 'video' | 'audio' | 'document' | 'sticker'; + fileName?: string; + mimetype?: string; +} + +export interface MessageOptions { + delay?: number; + presence?: 'unavailable' | 'available' | 'composing' | 'recording' | 'paused'; + linkPreview?: boolean; + mentionsEveryOne?: boolean; + mentioned?: string[]; + quoted?: { + key: { + remoteJid: string; + fromMe: boolean; + id: string; + }; + message: any; + }; +} + +export interface SendMessageRequest { + number: string; + content: MessageContent; + options?: MessageOptions; +} +``` + +## Webhook Types + +### Webhook Payload Types +```typescript +export interface WebhookPayload { + event: Events; + instance: string; + data: any; + timestamp: string; + server?: { + version: string; + host: string; + }; +} + +export interface WebhookConfig { + enabled: boolean; + url: string; + events: Events[]; + headers?: Record; + byEvents?: boolean; + base64?: boolean; +} +``` + +## Error Types + +### Custom Error Types +```typescript +export interface ApiError { + status: number; + message: string; + error?: string; + details?: any; + timestamp: string; + path: string; +} + +export interface ValidationError extends ApiError { + status: 400; + validationErrors: Array<{ + field: string; + message: string; + value?: any; + }>; +} + +export interface AuthenticationError extends ApiError { + status: 401; + message: 'Unauthorized' | 'Invalid API Key' | 'Token Expired'; +} +``` + +## Utility Types + +### Generic Utility Types +```typescript +export type Optional = Omit & Partial>; + +export type RequiredFields = T & Required>; + +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; + +export type NonEmptyArray = [T, ...T[]]; + +export type StringKeys = { + [K in keyof T]: T[K] extends string ? K : never; +}[keyof T]; +``` + +## Response Types + +### API Response Types +```typescript +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; + timestamp: string; +} + +export interface PaginatedResponse extends ApiResponse { + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; + }; +} + +export interface InstanceResponse extends ApiResponse { + instance: { + instanceName: string; + status: 'connecting' | 'open' | 'close' | 'qr'; + qrcode?: string; + profileName?: string; + profilePicUrl?: string; + }; +} +``` + +## Integration-Specific Types + +### Baileys Types Extension +```typescript +import { WASocket, ConnectionState, DisconnectReason } from 'baileys'; + +export interface BaileysInstance { + client: WASocket; + state: ConnectionState; + qrRetry: number; + authPath: string; +} + +export interface BaileysConfig { + qrTimeout: number; + maxQrRetries: number; + authTimeout: number; + reconnectInterval: number; +} +``` + +### Business API Types +```typescript +export interface BusinessApiConfig { + version: string; + baseUrl: string; + timeout: number; + retries: number; +} + +export interface BusinessApiMessage { + messaging_product: 'whatsapp'; + to: string; + type: 'text' | 'image' | 'document' | 'audio' | 'video' | 'template'; + text?: { + body: string; + preview_url?: boolean; + }; + image?: { + link?: string; + id?: string; + caption?: string; + }; + template?: { + name: string; + language: { + code: string; + }; + components?: any[]; + }; +} +``` + +## Type Guards + +### Type Guard Functions +```typescript +export function isMediaMessage(message: any): message is MediaMessage { + return message && TypeMediaMessage.some(type => message[type]); +} + +export function isTextMessage(message: any): message is TextMessage { + return message && message.conversation; +} + +export function isValidIntegration(integration: string): integration is IntegrationType { + return Object.values(Integration).includes(integration as IntegrationType); +} + +export function isValidEvent(event: string): event is Events { + return Object.values(Events).includes(event as Events); +} +``` + +## Module Augmentation + +### Express Request Extension +```typescript +declare global { + namespace Express { + interface Request { + instanceName?: string; + instanceData?: InstanceDto; + user?: { + id: string; + apiKey: string; + }; + } + } +} +``` + +## Type Documentation + +### JSDoc Type Documentation +```typescript +/** + * WhatsApp instance configuration + * @interface InstanceConfig + * @property {string} name - Unique instance name + * @property {IntegrationType} integration - Integration type + * @property {string} [token] - API token for business integrations + * @property {WebhookConfig} [webhook] - Webhook configuration + * @property {ProxyConfig} [proxy] - Proxy configuration + */ +export interface InstanceConfig { + name: string; + integration: IntegrationType; + token?: string; + webhook?: WebhookConfig; + proxy?: ProxyConfig; +} +``` \ No newline at end of file diff --git a/.cursor/rules/specialized-rules/util-rules.mdc b/.cursor/rules/specialized-rules/util-rules.mdc new file mode 100644 index 00000000..6bcf7dd1 --- /dev/null +++ b/.cursor/rules/specialized-rules/util-rules.mdc @@ -0,0 +1,653 @@ +--- +description: Utility functions and helpers for Evolution API +globs: + - "src/utils/**/*.ts" +alwaysApply: false +--- + +# Evolution API Utility Rules + +## Utility Function Structure + +### Standard Utility Pattern +```typescript +import { Logger } from '@config/logger.config'; + +const logger = new Logger('UtilityName'); + +export function utilityFunction(param: ParamType): ReturnType { + try { + // Utility logic + return result; + } catch (error) { + logger.error(`Utility function failed: ${error.message}`); + throw error; + } +} + +export default utilityFunction; +``` + +## Authentication Utilities + +### Multi-File Auth State Pattern +```typescript +import { AuthenticationState } from 'baileys'; +import { CacheService } from '@api/services/cache.service'; +import fs from 'fs/promises'; +import path from 'path'; + +export default async function useMultiFileAuthStatePrisma( + sessionId: string, + cache: CacheService, +): Promise<{ + state: AuthenticationState; + saveCreds: () => Promise; +}> { + const localFolder = path.join(INSTANCE_DIR, sessionId); + const localFile = (key: string) => path.join(localFolder, fixFileName(key) + '.json'); + await fs.mkdir(localFolder, { recursive: true }); + + async function writeData(data: any, key: string): Promise { + const dataString = JSON.stringify(data, BufferJSON.replacer); + + if (key !== 'creds') { + if (process.env.CACHE_REDIS_ENABLED === 'true') { + return await cache.hSet(sessionId, key, data); + } else { + await fs.writeFile(localFile(key), dataString); + return; + } + } + await saveKey(sessionId, dataString); + return; + } + + async function readData(key: string): Promise { + try { + let rawData; + + if (key !== 'creds') { + if (process.env.CACHE_REDIS_ENABLED === 'true') { + return await cache.hGet(sessionId, key); + } else { + if (!(await fileExists(localFile(key)))) return null; + rawData = await fs.readFile(localFile(key), { encoding: 'utf-8' }); + return JSON.parse(rawData, BufferJSON.reviver); + } + } else { + rawData = await getAuthKey(sessionId); + } + + const parsedData = JSON.parse(rawData, BufferJSON.reviver); + return parsedData; + } catch (error) { + return null; + } + } + + async function removeData(key: string): Promise { + try { + if (key !== 'creds') { + if (process.env.CACHE_REDIS_ENABLED === 'true') { + return await cache.hDelete(sessionId, key); + } else { + await fs.unlink(localFile(key)); + } + } else { + await deleteAuthKey(sessionId); + } + } catch (error) { + return; + } + } + + let creds = await readData('creds'); + if (!creds) { + creds = initAuthCreds(); + await writeData(creds, 'creds'); + } + + return { + state: { + creds, + keys: { + get: async (type, ids) => { + const data = {}; + await Promise.all( + ids.map(async (id) => { + let value = await readData(`${type}-${id}`); + if (type === 'app-state-sync-key' && value) { + value = proto.Message.AppStateSyncKeyData.fromObject(value); + } + data[id] = value; + }) + ); + return data; + }, + set: async (data) => { + const tasks = []; + for (const category in data) { + for (const id in data[category]) { + const value = data[category][id]; + const key = `${category}-${id}`; + tasks.push(value ? writeData(value, key) : removeData(key)); + } + } + await Promise.all(tasks); + }, + }, + }, + saveCreds: () => writeData(creds, 'creds'), + }; +} +``` + +## Message Processing Utilities + +### Message Content Extraction +```typescript +export const getConversationMessage = (msg: any): string => { + const types = getTypeMessage(msg); + const messageContent = getMessageContent(types); + return messageContent; +}; + +const getTypeMessage = (msg: any): any => { + return Object.keys(msg?.message || msg || {})[0]; +}; + +const getMessageContent = (type: string, msg?: any): string => { + const typeKey = type?.replace('Message', ''); + + const types = { + conversation: msg?.message?.conversation, + extendedTextMessage: msg?.message?.extendedTextMessage?.text, + imageMessage: msg?.message?.imageMessage?.caption || 'Image', + videoMessage: msg?.message?.videoMessage?.caption || 'Video', + audioMessage: 'Audio', + documentMessage: msg?.message?.documentMessage?.caption || 'Document', + stickerMessage: 'Sticker', + contactMessage: 'Contact', + locationMessage: 'Location', + liveLocationMessage: 'Live Location', + viewOnceMessage: 'View Once', + reactionMessage: 'Reaction', + pollCreationMessage: 'Poll', + pollUpdateMessage: 'Poll Update', + }; + + let result = types[typeKey] || types[type] || 'Unknown'; + + if (!result || result === 'Unknown') { + result = JSON.stringify(msg); + } + + return result; +}; +``` + +### JID Creation Utility +```typescript +export const createJid = (number: string): string => { + if (number.includes('@')) { + return number; + } + + // Remove any non-numeric characters except + + let cleanNumber = number.replace(/[^\d+]/g, ''); + + // Remove + if present + if (cleanNumber.startsWith('+')) { + cleanNumber = cleanNumber.substring(1); + } + + // Add country code if missing (assuming Brazil as default) + if (cleanNumber.length === 11 && cleanNumber.startsWith('11')) { + cleanNumber = '55' + cleanNumber; + } else if (cleanNumber.length === 10) { + cleanNumber = '5511' + cleanNumber; + } + + // Determine if it's a group or individual + const isGroup = cleanNumber.includes('-'); + const domain = isGroup ? 'g.us' : 's.whatsapp.net'; + + return `${cleanNumber}@${domain}`; +}; +``` + +## Cache Utilities + +### WhatsApp Number Cache +```typescript +interface ISaveOnWhatsappCacheParams { + remoteJid: string; + lid?: string; +} + +function getAvailableNumbers(remoteJid: string): string[] { + const numbersAvailable: string[] = []; + + if (remoteJid.startsWith('+')) { + remoteJid = remoteJid.slice(1); + } + + const [number, domain] = remoteJid.split('@'); + + // Brazilian numbers + if (remoteJid.startsWith('55')) { + const numberWithDigit = + number.slice(4, 5) === '9' && number.length === 13 ? number : `${number.slice(0, 4)}9${number.slice(4)}`; + const numberWithoutDigit = number.length === 12 ? number : number.slice(0, 4) + number.slice(5); + + numbersAvailable.push(numberWithDigit); + numbersAvailable.push(numberWithoutDigit); + } + // Mexican/Argentina numbers + else if (number.startsWith('52') || number.startsWith('54')) { + let prefix = ''; + if (number.startsWith('52')) { + prefix = '1'; + } + if (number.startsWith('54')) { + prefix = '9'; + } + + const numberWithDigit = + number.slice(2, 3) === prefix && number.length === 13 + ? number + : `${number.slice(0, 2)}${prefix}${number.slice(2)}`; + const numberWithoutDigit = number.length === 12 ? number : number.slice(0, 2) + number.slice(3); + + numbersAvailable.push(numberWithDigit); + numbersAvailable.push(numberWithoutDigit); + } + // Other countries + else { + numbersAvailable.push(remoteJid); + } + + return numbersAvailable.map((number) => `${number}@${domain}`); +} + +export async function saveOnWhatsappCache(params: ISaveOnWhatsappCacheParams): Promise { + const { remoteJid, lid } = params; + const db = configService.get('DATABASE'); + + if (!db.SAVE_DATA.CONTACTS) { + return; + } + + try { + const numbersAvailable = getAvailableNumbers(remoteJid); + + const existingContact = await prismaRepository.contact.findFirst({ + where: { + OR: numbersAvailable.map(number => ({ id: number })), + }, + }); + + if (!existingContact) { + await prismaRepository.contact.create({ + data: { + id: remoteJid, + pushName: '', + profilePicUrl: '', + isOnWhatsapp: true, + lid: lid || null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + } else { + await prismaRepository.contact.update({ + where: { id: existingContact.id }, + data: { + isOnWhatsapp: true, + lid: lid || existingContact.lid, + updatedAt: new Date(), + }, + }); + } + } catch (error) { + console.error('Error saving WhatsApp cache:', error); + } +} +``` + +## Search Utilities + +### Advanced Search Operators +```typescript +function normalizeString(str: string): string { + return str + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); +} + +export function advancedOperatorsSearch(data: string, query: string): boolean { + const normalizedData = normalizeString(data); + const normalizedQuery = normalizeString(query); + + // Exact phrase search with quotes + if (normalizedQuery.startsWith('"') && normalizedQuery.endsWith('"')) { + const phrase = normalizedQuery.slice(1, -1); + return normalizedData.includes(phrase); + } + + // OR operator + if (normalizedQuery.includes(' OR ')) { + const terms = normalizedQuery.split(' OR '); + return terms.some(term => normalizedData.includes(term.trim())); + } + + // AND operator (default behavior) + if (normalizedQuery.includes(' AND ')) { + const terms = normalizedQuery.split(' AND '); + return terms.every(term => normalizedData.includes(term.trim())); + } + + // NOT operator + if (normalizedQuery.startsWith('NOT ')) { + const term = normalizedQuery.slice(4); + return !normalizedData.includes(term); + } + + // Wildcard search + if (normalizedQuery.includes('*')) { + const regex = new RegExp(normalizedQuery.replace(/\*/g, '.*'), 'i'); + return regex.test(normalizedData); + } + + // Default: simple contains search + return normalizedData.includes(normalizedQuery); +} +``` + +## Proxy Utilities + +### Proxy Agent Creation +```typescript +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { SocksProxyAgent } from 'socks-proxy-agent'; + +type Proxy = { + host: string; + port: string; + protocol: 'http' | 'https' | 'socks4' | 'socks5'; + username?: string; + password?: string; +}; + +function selectProxyAgent(proxyUrl: string): HttpsProxyAgent | SocksProxyAgent { + const url = new URL(proxyUrl); + + if (url.protocol === 'socks4:' || url.protocol === 'socks5:') { + return new SocksProxyAgent(proxyUrl); + } else { + return new HttpsProxyAgent(proxyUrl); + } +} + +export function makeProxyAgent(proxy: Proxy): HttpsProxyAgent | SocksProxyAgent | null { + if (!proxy.host || !proxy.port) { + return null; + } + + let proxyUrl = `${proxy.protocol}://`; + + if (proxy.username && proxy.password) { + proxyUrl += `${proxy.username}:${proxy.password}@`; + } + + proxyUrl += `${proxy.host}:${proxy.port}`; + + try { + return selectProxyAgent(proxyUrl); + } catch (error) { + console.error('Failed to create proxy agent:', error); + return null; + } +} +``` + +## Telemetry Utilities + +### Telemetry Data Collection +```typescript +export interface TelemetryData { + route: string; + apiVersion: string; + timestamp: Date; + method?: string; + statusCode?: number; + responseTime?: number; + userAgent?: string; + instanceName?: string; +} + +export const sendTelemetry = async (route: string): Promise => { + try { + const telemetryData: TelemetryData = { + route, + apiVersion: packageJson.version, + timestamp: new Date(), + }; + + // Only send telemetry if enabled + if (process.env.DISABLE_TELEMETRY === 'true') { + return; + } + + // Send to telemetry service (implement as needed) + await axios.post('https://telemetry.evolution-api.com/collect', telemetryData, { + timeout: 5000, + }); + } catch (error) { + // Silently fail - don't affect main application + console.debug('Telemetry failed:', error.message); + } +}; +``` + +## Internationalization Utilities + +### i18n Setup +```typescript +import { ConfigService, Language } from '@config/env.config'; +import fs from 'fs'; +import i18next from 'i18next'; +import path from 'path'; + +const __dirname = path.resolve(process.cwd(), 'src', 'utils'); +const languages = ['en', 'pt-BR', 'es']; +const translationsPath = path.join(__dirname, 'translations'); +const configService: ConfigService = new ConfigService(); + +const resources: any = {}; + +languages.forEach((language) => { + const languagePath = path.join(translationsPath, `${language}.json`); + if (fs.existsSync(languagePath)) { + const translationContent = fs.readFileSync(languagePath, 'utf8'); + resources[language] = { + translation: JSON.parse(translationContent), + }; + } +}); + +i18next.init({ + resources, + fallbackLng: 'en', + lng: configService.get('LANGUAGE') || 'pt-BR', + interpolation: { + escapeValue: false, + }, +}); + +export const t = i18next.t.bind(i18next); +export default i18next; +``` + +## Bot Trigger Utilities + +### Bot Trigger Matching +```typescript +import { TriggerOperator, TriggerType } from '@prisma/client'; + +export function findBotByTrigger( + bots: any[], + content: string, + remoteJid: string, +): any | null { + for (const bot of bots) { + if (!bot.enabled) continue; + + // Check ignore list + if (bot.ignoreJids && bot.ignoreJids.includes(remoteJid)) { + continue; + } + + // Check trigger + if (matchesTrigger(content, bot.triggerType, bot.triggerOperator, bot.triggerValue)) { + return bot; + } + } + + return null; +} + +function matchesTrigger( + content: string, + triggerType: TriggerType, + triggerOperator: TriggerOperator, + triggerValue: string, +): boolean { + const normalizedContent = content.toLowerCase().trim(); + const normalizedValue = triggerValue.toLowerCase().trim(); + + switch (triggerType) { + case TriggerType.ALL: + return true; + + case TriggerType.KEYWORD: + return matchesKeyword(normalizedContent, triggerOperator, normalizedValue); + + case TriggerType.REGEX: + try { + const regex = new RegExp(triggerValue, 'i'); + return regex.test(content); + } catch { + return false; + } + + default: + return false; + } +} + +function matchesKeyword( + content: string, + operator: TriggerOperator, + value: string, +): boolean { + switch (operator) { + case TriggerOperator.EQUALS: + return content === value; + case TriggerOperator.CONTAINS: + return content.includes(value); + case TriggerOperator.STARTS_WITH: + return content.startsWith(value); + case TriggerOperator.ENDS_WITH: + return content.endsWith(value); + default: + return false; + } +} +``` + +## Server Utilities + +### Server Status Check +```typescript +export class ServerUP { + private static instance: ServerUP; + private isServerUp: boolean = false; + + private constructor() {} + + public static getInstance(): ServerUP { + if (!ServerUP.instance) { + ServerUP.instance = new ServerUP(); + } + return ServerUP.instance; + } + + public setServerStatus(status: boolean): void { + this.isServerUp = status; + } + + public getServerStatus(): boolean { + return this.isServerUp; + } + + public async waitForServer(timeout: number = 30000): Promise { + const startTime = Date.now(); + + while (!this.isServerUp && (Date.now() - startTime) < timeout) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + return this.isServerUp; + } +} +``` + +## Error Response Utilities + +### Standardized Error Responses +```typescript +export function createMetaErrorResponse(error: any, context: string) { + const timestamp = new Date().toISOString(); + + if (error.response?.data) { + return { + status: error.response.status || 500, + error: { + message: error.response.data.error?.message || 'External API error', + type: error.response.data.error?.type || 'api_error', + code: error.response.data.error?.code || 'unknown_error', + context, + timestamp, + }, + }; + } + + return { + status: 500, + error: { + message: error.message || 'Internal server error', + type: 'internal_error', + code: 'server_error', + context, + timestamp, + }, + }; +} + +export function createValidationErrorResponse(errors: any[], context: string) { + return { + status: 400, + error: { + message: 'Validation failed', + type: 'validation_error', + code: 'invalid_input', + context, + details: errors, + timestamp: new Date().toISOString(), + }, + }; +} +``` \ No newline at end of file diff --git a/.cursor/rules/specialized-rules/validate-rules.mdc b/.cursor/rules/specialized-rules/validate-rules.mdc new file mode 100644 index 00000000..f6baea1b --- /dev/null +++ b/.cursor/rules/specialized-rules/validate-rules.mdc @@ -0,0 +1,498 @@ +--- +description: Validation schemas and patterns for Evolution API +globs: + - "src/validate/**/*.ts" +alwaysApply: false +--- + +# Evolution API Validation Rules + +## Validation Schema Structure + +### JSONSchema7 Pattern (Evolution API Standard) +```typescript +import { JSONSchema7 } from 'json-schema'; +import { v4 } from 'uuid'; + +const isNotEmpty = (...fields: string[]) => { + const properties = {}; + fields.forEach((field) => { + properties[field] = { + if: { properties: { [field]: { type: 'string' } } }, + then: { properties: { [field]: { minLength: 1 } } }, + }; + }); + + return { + allOf: Object.values(properties), + }; +}; + +export const exampleSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + enabled: { type: 'boolean' }, + settings: { + type: 'object', + properties: { + timeout: { type: 'number', minimum: 1000, maximum: 60000 }, + retries: { type: 'number', minimum: 0, maximum: 5 }, + }, + }, + tags: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['name', 'enabled'], + ...isNotEmpty('name'), +}; +``` + +## Message Validation Schemas + +### Send Message Validation +```typescript +const numberDefinition = { + type: 'string', + pattern: '^\\d+[\\.@\\w-]+', + description: 'Invalid number', +}; + +export const sendTextSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + number: numberDefinition, + text: { type: 'string', minLength: 1, maxLength: 4096 }, + delay: { type: 'number', minimum: 0, maximum: 60000 }, + linkPreview: { type: 'boolean' }, + mentionsEveryOne: { type: 'boolean' }, + mentioned: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['number', 'text'], + ...isNotEmpty('number', 'text'), +}; + +export const sendMediaSchema = Joi.object({ + number: Joi.string().required().pattern(/^\d+$/), + mediatype: Joi.string().required().valid('image', 'video', 'audio', 'document'), + media: Joi.alternatives().try( + Joi.string().uri(), + Joi.string().base64(), + ).required(), + caption: Joi.string().optional().max(1024), + fileName: Joi.string().optional().max(255), + delay: Joi.number().optional().min(0).max(60000), +}).required(); + +export const sendButtonsSchema = Joi.object({ + number: Joi.string().required().pattern(/^\d+$/), + title: Joi.string().required().max(1024), + description: Joi.string().optional().max(1024), + footer: Joi.string().optional().max(60), + buttons: Joi.array().items( + Joi.object({ + type: Joi.string().required().valid('replyButton', 'urlButton', 'callButton'), + displayText: Joi.string().required().max(20), + id: Joi.string().when('type', { + is: 'replyButton', + then: Joi.required().max(256), + otherwise: Joi.optional(), + }), + url: Joi.string().when('type', { + is: 'urlButton', + then: Joi.required().uri(), + otherwise: Joi.optional(), + }), + phoneNumber: Joi.string().when('type', { + is: 'callButton', + then: Joi.required().pattern(/^\+?\d+$/), + otherwise: Joi.optional(), + }), + }) + ).min(1).max(3).required(), +}).required(); +``` + +## Instance Validation Schemas + +### Instance Creation Validation +```typescript +export const instanceSchema = Joi.object({ + instanceName: Joi.string().required().min(1).max(100).pattern(/^[a-zA-Z0-9_-]+$/), + integration: Joi.string().required().valid('WHATSAPP-BAILEYS', 'WHATSAPP-BUSINESS', 'EVOLUTION'), + token: Joi.string().when('integration', { + is: Joi.valid('WHATSAPP-BUSINESS', 'EVOLUTION'), + then: Joi.required().min(10), + otherwise: Joi.optional(), + }), + qrcode: Joi.boolean().optional().default(false), + number: Joi.string().optional().pattern(/^\d+$/), + businessId: Joi.string().when('integration', { + is: 'WHATSAPP-BUSINESS', + then: Joi.required(), + otherwise: Joi.optional(), + }), +}).required(); + +export const settingsSchema = Joi.object({ + rejectCall: Joi.boolean().optional(), + msgCall: Joi.string().optional().max(500), + groupsIgnore: Joi.boolean().optional(), + alwaysOnline: Joi.boolean().optional(), + readMessages: Joi.boolean().optional(), + readStatus: Joi.boolean().optional(), + syncFullHistory: Joi.boolean().optional(), + wavoipToken: Joi.string().optional(), +}).optional(); + +export const proxySchema = Joi.object({ + host: Joi.string().required().hostname(), + port: Joi.string().required().pattern(/^\d+$/).custom((value) => { + const port = parseInt(value); + if (port < 1 || port > 65535) { + throw new Error('Port must be between 1 and 65535'); + } + return value; + }), + protocol: Joi.string().required().valid('http', 'https', 'socks4', 'socks5'), + username: Joi.string().optional(), + password: Joi.string().optional(), +}).optional(); +``` + +## Webhook Validation Schemas + +### Webhook Configuration Validation +```typescript +export const webhookSchema = Joi.object({ + enabled: Joi.boolean().required(), + url: Joi.string().when('enabled', { + is: true, + then: Joi.required().uri({ scheme: ['http', 'https'] }), + otherwise: Joi.optional(), + }), + events: Joi.array().items( + Joi.string().valid( + 'APPLICATION_STARTUP', + 'INSTANCE_CREATE', + 'INSTANCE_DELETE', + 'QRCODE_UPDATED', + 'CONNECTION_UPDATE', + 'STATUS_INSTANCE', + 'MESSAGES_SET', + 'MESSAGES_UPSERT', + 'MESSAGES_UPDATE', + 'MESSAGES_DELETE', + 'CONTACTS_SET', + 'CONTACTS_UPSERT', + 'CONTACTS_UPDATE', + 'CHATS_SET', + 'CHATS_UPDATE', + 'CHATS_UPSERT', + 'CHATS_DELETE', + 'GROUPS_UPSERT', + 'GROUPS_UPDATE', + 'GROUP_PARTICIPANTS_UPDATE', + 'CALL' + ) + ).min(1).when('enabled', { + is: true, + then: Joi.required(), + otherwise: Joi.optional(), + }), + headers: Joi.object().pattern( + Joi.string(), + Joi.string() + ).optional(), + byEvents: Joi.boolean().optional().default(false), + base64: Joi.boolean().optional().default(false), +}).required(); +``` + +## Chatbot Validation Schemas + +### Base Chatbot Validation +```typescript +export const baseChatbotSchema = Joi.object({ + enabled: Joi.boolean().required(), + description: Joi.string().required().min(1).max(500), + expire: Joi.number().optional().min(0).max(86400), // 24 hours in seconds + keywordFinish: Joi.string().optional().max(100), + delayMessage: Joi.number().optional().min(0).max(10000), + unknownMessage: Joi.string().optional().max(1000), + listeningFromMe: Joi.boolean().optional().default(false), + stopBotFromMe: Joi.boolean().optional().default(false), + keepOpen: Joi.boolean().optional().default(false), + debounceTime: Joi.number().optional().min(0).max(60000), + triggerType: Joi.string().required().valid('ALL', 'KEYWORD', 'REGEX'), + triggerOperator: Joi.string().when('triggerType', { + is: 'KEYWORD', + then: Joi.required().valid('EQUALS', 'CONTAINS', 'STARTS_WITH', 'ENDS_WITH'), + otherwise: Joi.optional(), + }), + triggerValue: Joi.string().when('triggerType', { + is: Joi.valid('KEYWORD', 'REGEX'), + then: Joi.required().min(1).max(500), + otherwise: Joi.optional(), + }), + ignoreJids: Joi.array().items(Joi.string()).optional(), + splitMessages: Joi.boolean().optional().default(false), + timePerChar: Joi.number().optional().min(10).max(1000).default(100), +}).required(); + +export const typebotSchema = baseChatbotSchema.keys({ + url: Joi.string().required().uri({ scheme: ['http', 'https'] }), + typebot: Joi.string().required().min(1).max(100), + apiVersion: Joi.string().optional().valid('v1', 'v2').default('v1'), +}).required(); + +export const openaiSchema = baseChatbotSchema.keys({ + apiKey: Joi.string().required().min(10), + model: Joi.string().optional().valid( + 'gpt-3.5-turbo', + 'gpt-3.5-turbo-16k', + 'gpt-4', + 'gpt-4-32k', + 'gpt-4-turbo-preview' + ).default('gpt-3.5-turbo'), + systemMessage: Joi.string().optional().max(2000), + maxTokens: Joi.number().optional().min(1).max(4000).default(1000), + temperature: Joi.number().optional().min(0).max(2).default(0.7), +}).required(); +``` + +## Business API Validation Schemas + +### Template Validation +```typescript +export const templateSchema = Joi.object({ + name: Joi.string().required().min(1).max(512).pattern(/^[a-z0-9_]+$/), + category: Joi.string().required().valid('MARKETING', 'UTILITY', 'AUTHENTICATION'), + allowCategoryChange: Joi.boolean().required(), + language: Joi.string().required().pattern(/^[a-z]{2}_[A-Z]{2}$/), // e.g., pt_BR, en_US + components: Joi.array().items( + Joi.object({ + type: Joi.string().required().valid('HEADER', 'BODY', 'FOOTER', 'BUTTONS'), + format: Joi.string().when('type', { + is: 'HEADER', + then: Joi.valid('TEXT', 'IMAGE', 'VIDEO', 'DOCUMENT'), + otherwise: Joi.optional(), + }), + text: Joi.string().when('type', { + is: Joi.valid('HEADER', 'BODY', 'FOOTER'), + then: Joi.required().min(1).max(1024), + otherwise: Joi.optional(), + }), + buttons: Joi.array().when('type', { + is: 'BUTTONS', + then: Joi.items( + Joi.object({ + type: Joi.string().required().valid('QUICK_REPLY', 'URL', 'PHONE_NUMBER'), + text: Joi.string().required().min(1).max(25), + url: Joi.string().when('type', { + is: 'URL', + then: Joi.required().uri(), + otherwise: Joi.optional(), + }), + phone_number: Joi.string().when('type', { + is: 'PHONE_NUMBER', + then: Joi.required().pattern(/^\+?\d+$/), + otherwise: Joi.optional(), + }), + }) + ).min(1).max(10), + otherwise: Joi.optional(), + }), + }) + ).min(1).required(), + webhookUrl: Joi.string().optional().uri({ scheme: ['http', 'https'] }), +}).required(); + +export const catalogSchema = Joi.object({ + number: Joi.string().optional().pattern(/^\d+$/), + limit: Joi.number().optional().min(1).max(1000).default(10), + cursor: Joi.string().optional(), +}).optional(); +``` + +## Group Validation Schemas + +### Group Management Validation +```typescript +export const createGroupSchema = Joi.object({ + subject: Joi.string().required().min(1).max(100), + description: Joi.string().optional().max(500), + participants: Joi.array().items( + Joi.string().pattern(/^\d+$/) + ).min(1).max(256).required(), + promoteParticipants: Joi.boolean().optional().default(false), +}).required(); + +export const updateGroupSchema = Joi.object({ + subject: Joi.string().optional().min(1).max(100), + description: Joi.string().optional().max(500), +}).min(1).required(); + +export const groupParticipantsSchema = Joi.object({ + participants: Joi.array().items( + Joi.string().pattern(/^\d+$/) + ).min(1).max(50).required(), + action: Joi.string().required().valid('add', 'remove', 'promote', 'demote'), +}).required(); +``` + +## Label Validation Schemas + +### Label Management Validation +```typescript +export const labelSchema = Joi.object({ + name: Joi.string().required().min(1).max(100), + color: Joi.string().required().pattern(/^#[0-9A-Fa-f]{6}$/), // Hex color + predefinedId: Joi.string().optional(), +}).required(); + +export const handleLabelSchema = Joi.object({ + number: Joi.string().required().pattern(/^\d+$/), + labelId: Joi.string().required(), + action: Joi.string().required().valid('add', 'remove'), +}).required(); +``` + +## Custom Validation Functions + +### Phone Number Validation +```typescript +export function validatePhoneNumber(number: string): boolean { + // Remove any non-digit characters + const cleaned = number.replace(/\D/g, ''); + + // Check minimum and maximum length + if (cleaned.length < 10 || cleaned.length > 15) { + return false; + } + + // Check for valid country codes (basic validation) + const validCountryCodes = ['1', '7', '20', '27', '30', '31', '32', '33', '34', '36', '39', '40', '41', '43', '44', '45', '46', '47', '48', '49', '51', '52', '53', '54', '55', '56', '57', '58', '60', '61', '62', '63', '64', '65', '66', '81', '82', '84', '86', '90', '91', '92', '93', '94', '95', '98']; + + // Check if starts with valid country code + const startsWithValidCode = validCountryCodes.some(code => cleaned.startsWith(code)); + + return startsWithValidCode; +} + +export const phoneNumberValidator = Joi.string().custom((value, helpers) => { + if (!validatePhoneNumber(value)) { + return helpers.error('any.invalid'); + } + return value; +}, 'Phone number validation'); +``` + +### Base64 Validation +```typescript +export function validateBase64(base64: string): boolean { + try { + // Check if it's a valid base64 string + const decoded = Buffer.from(base64, 'base64').toString('base64'); + return decoded === base64; + } catch { + return false; + } +} + +export const base64Validator = Joi.string().custom((value, helpers) => { + if (!validateBase64(value)) { + return helpers.error('any.invalid'); + } + return value; +}, 'Base64 validation'); +``` + +### URL Validation with Protocol Check +```typescript +export function validateWebhookUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ['http:', 'https:'].includes(parsed.protocol); + } catch { + return false; + } +} + +export const webhookUrlValidator = Joi.string().custom((value, helpers) => { + if (!validateWebhookUrl(value)) { + return helpers.error('any.invalid'); + } + return value; +}, 'Webhook URL validation'); +``` + +## Validation Error Handling + +### Error Message Customization +```typescript +export const validationMessages = { + 'any.required': 'O campo {#label} é obrigatório', + 'string.empty': 'O campo {#label} não pode estar vazio', + 'string.min': 'O campo {#label} deve ter pelo menos {#limit} caracteres', + 'string.max': 'O campo {#label} deve ter no máximo {#limit} caracteres', + 'string.pattern.base': 'O campo {#label} possui formato inválido', + 'number.min': 'O campo {#label} deve ser maior ou igual a {#limit}', + 'number.max': 'O campo {#label} deve ser menor ou igual a {#limit}', + 'array.min': 'O campo {#label} deve ter pelo menos {#limit} itens', + 'array.max': 'O campo {#label} deve ter no máximo {#limit} itens', + 'any.only': 'O campo {#label} deve ser um dos valores: {#valids}', +}; + +export function formatValidationError(error: Joi.ValidationError): any { + return { + message: 'Dados de entrada inválidos', + details: error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message, + value: detail.context?.value, + })), + }; +} +``` + +## Schema Composition + +### Reusable Schema Components +```typescript +export const commonFields = { + instanceName: Joi.string().required().min(1).max(100).pattern(/^[a-zA-Z0-9_-]+$/), + number: phoneNumberValidator.required(), + delay: Joi.number().optional().min(0).max(60000), + enabled: Joi.boolean().optional().default(true), +}; + +export const mediaFields = { + mediatype: Joi.string().required().valid('image', 'video', 'audio', 'document'), + media: Joi.alternatives().try( + Joi.string().uri(), + base64Validator, + ).required(), + caption: Joi.string().optional().max(1024), + fileName: Joi.string().optional().max(255), +}; + +// Compose schemas using common fields +export const quickMessageSchema = Joi.object({ + ...commonFields, + text: Joi.string().required().min(1).max(4096), +}).required(); + +export const quickMediaSchema = Joi.object({ + ...commonFields, + ...mediaFields, +}).required(); +``` \ No newline at end of file diff --git a/.gitignore b/.gitignore index 634c6cb0..768d8afa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,12 +4,8 @@ Baileys /dist /node_modules -.cursor* - /Docker/.env -.vscode - # Logs logs/**.json *.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..da708130 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `src/` – TypeScript source. Key areas: `api/controllers`, `api/routes`, `api/services`, `api/integrations/{channel,chatbot,event,storage}`, `config`, `utils`, `exceptions`. +- `prisma/` – Prisma schema and migrations. Provider folders: `postgresql-migrations/`, `mysql-migrations/`. Use `DATABASE_PROVIDER` to target the provider. +- `dist/` – Build output; do not edit. +- `public/` – Static assets. +- `Docker*`, `docker-compose*.yaml` – Local stack and deployment helpers. + +## Build, Test, and Development Commands +- `npm run build` – Type-check (tsc) and bundle with `tsup` to `dist/`. +- `npm run start` – Run dev server via `tsx src/main.ts`. +- `npm run dev:server` – Watch mode for local development. +- `npm run start:prod` – Run compiled app from `dist/`. +- `npm run lint` / `npm run lint:check` – Auto-fix and check linting. +- Database (choose provider): `export DATABASE_PROVIDER=postgresql` (or `mysql`), then: + - `npm run db:generate` – Generate Prisma client. + - `npm run db:migrate:dev` – Apply dev migrations and sync provider folder. + - `npm run db:deploy` – Apply migrations in non-dev environments. + - `npm run db:studio` – Open Prisma Studio. +- Docker: `docker-compose up -d` to start local services. + +## Coding Style & Naming Conventions +- TypeScript, 2-space indent, single quotes, trailing commas, 120-char max (Prettier). +- Enforced by ESLint + Prettier; import order via `simple-import-sort`. +- File names follow `feature.kind.ts` (e.g., `chat.router.ts`, `whatsapp.baileys.service.ts`). +- Classes: PascalCase; functions/variables: camelCase; constants: UPPER_SNAKE_CASE. + +## Testing Guidelines +- No formal suite yet. Place tests under `test/` as `*.test.ts`. +- Run `npm test` (watches `test/all.test.ts` if present). Prefer fast, isolated unit tests. + +## Commit & Pull Request Guidelines +- Conventional Commits enforced by commitlint. Use `npm run commit` (Commitizen). + - Examples: `feat(api): add message status`, `fix(route): handle 404 on send`. +- PRs: include clear description, linked issues, migration impact (provider), local run steps, and screenshots/logs where relevant. + +## Security & Configuration +- Copy `.env.example` to `.env`; never commit secrets. +- Set `DATABASE_PROVIDER` before DB commands; see `SECURITY.md` for reporting vulnerabilities. + diff --git a/CLAUDE.md b/CLAUDE.md index e452214b..949045d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,77 +2,163 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Development Commands +## Project Overview -### Core Commands -- **Run development server**: `npm run dev:server` - Starts the server with hot reload using tsx watch -- **Build project**: `npm run build` - Runs TypeScript check and builds with tsup -- **Start production**: `npm run start:prod` - Runs the compiled application from dist/ -- **Lint code**: `npm run lint` - Runs ESLint with auto-fix on TypeScript files -- **Check lint**: `npm run lint:check` - Runs ESLint without auto-fix +Evolution API is a REST API for WhatsApp communication that supports both Baileys (WhatsApp Web) and official WhatsApp Business API. It's built with TypeScript/Node.js and provides extensive integrations with various platforms. -### Database Commands -The project uses Prisma with support for multiple database providers (PostgreSQL, MySQL, psql_bouncer). Commands automatically use the DATABASE_PROVIDER from .env: +## Common Development Commands -- **Generate Prisma client**: `npm run db:generate` -- **Deploy migrations**: `npm run db:deploy` (Unix/Mac) or `npm run db:deploy:win` (Windows) -- **Open Prisma Studio**: `npm run db:studio` -- **Create new migration**: `npm run db:migrate:dev` (Unix/Mac) or `npm run db:migrate:dev:win` (Windows) +### Build and Run +```bash +# Development +npm run dev:server # Run in development with hot reload (tsx watch) + +# Production +npm run build # TypeScript check + tsup build +npm run start:prod # Run production build + +# Direct execution +npm start # Run with tsx +``` + +### Code Quality +```bash +npm run lint # ESLint with auto-fix +npm run lint:check # ESLint check only +npm run commit # Interactive commit with commitizen +``` + +### Database Management +```bash +# Generate Prisma client (automatically uses DATABASE_PROVIDER env) +npm run db:generate + +# Deploy migrations +npm run db:deploy # Unix/Mac +npm run db:deploy:win # Windows + +# Open Prisma Studio +npm run db:studio + +# Development migrations +npm run db:migrate:dev # Unix/Mac +npm run db:migrate:dev:win # Windows +``` + +### Testing +```bash +npm test # Run tests with watch mode +``` ## Architecture Overview -### Project Structure -Evolution API is a WhatsApp integration platform built with TypeScript and Express, supporting both Baileys (WhatsApp Web) and WhatsApp Cloud API connections. +### Core Structure +- **Multi-provider database support**: PostgreSQL and MySQL via Prisma ORM with provider-specific schemas +- **Connection management**: Each WhatsApp instance maintains its own connection state and session +- **Event-driven architecture**: Uses EventEmitter2 for internal events and supports multiple external event systems -### Core Components +### Directory Layout +``` +src/ +├── api/ +│ ├── controllers/ # HTTP route handlers +│ ├── services/ # Business logic +│ ├── repository/ # Data access layer (Prisma) +│ ├── dto/ # Data validation schemas +│ ├── guards/ # Authentication/authorization +│ ├── integrations/ # External service integrations +│ └── routes/ # Express route definitions +├── config/ # Environment and app configuration +├── cache/ # Redis and local cache implementations +├── exceptions/ # Custom exception classes +├── utils/ # Shared utilities +└── validate/ # Validation schemas +``` -**API Layer** (`src/api/`) -- **Controllers**: Handle HTTP requests for different resources (instance, chat, group, sendMessage, etc.) -- **Services**: Business logic layer containing auth, cache, channel, monitor, proxy services -- **Routes**: RESTful API endpoints with authentication guards -- **DTOs**: Data transfer objects for request/response validation using class-validator -- **Repository**: Database access layer using Prisma ORM +### Key Services Integration Points -**Integrations** (`src/api/integrations/`) -- **Chatbot**: Supports multiple chatbot platforms (Typebot, Chatwoot, Dify, OpenAI, Flowise, N8N) -- **Event**: WebSocket, RabbitMQ, Amazon SQS event systems -- **Storage**: S3/Minio file storage integration -- **Channel**: Multi-channel messaging support +**WhatsApp Service** (`src/api/integrations/channel/whatsapp/`): +- Manages Baileys connections and Meta Business API +- Handles message sending, receiving, and status updates +- Connection lifecycle management per instance -**Configuration** (`src/config/`) -- Environment configuration management -- Database provider switching (PostgreSQL/MySQL/PgBouncer) -- Multi-tenant support via DATABASE_CONNECTION_CLIENT_NAME +**Integration Services** (`src/api/integrations/`): +- Chatwoot: Customer service platform integration +- Typebot: Conversational bot builder +- OpenAI: AI capabilities including audio transcription +- Dify: AI agent platform +- RabbitMQ/SQS: Message queue integrations +- S3/Minio: Media storage -### Key Design Patterns - -1. **Multi-Provider Database**: Uses `runWithProvider.js` to dynamically select database provider and migrations -2. **Module System**: Path aliases configured in tsconfig.json (@api, @cache, @config, @utils, @validate) -3. **Event-Driven**: EventEmitter2 for internal events, supports multiple external event systems -4. **Instance Management**: Each WhatsApp connection is managed as an instance with memory lifecycle (DEL_INSTANCE config) - -### Database Schema -- Supports multiple providers with provider-specific schemas in `prisma/` -- Separate migration folders for each provider (postgresql-migrations, mysql-migrations) -- psql_bouncer uses PostgreSQL migrations but with connection pooling +### Database Schema Management +- Separate schema files: `postgresql-schema.prisma` and `mysql-schema.prisma` +- Environment variable `DATABASE_PROVIDER` determines active database +- Migration folders are provider-specific and auto-selected during deployment ### Authentication & Security -- JWT-based authentication -- API key support -- Instance-specific authentication -- Configurable CORS settings +- API key-based authentication via `apikey` header +- Instance-specific authentication for WhatsApp connections +- Guards system for route protection +- Input validation using `class-validator` -### Messaging Features -- WhatsApp Web (Baileys library) and WhatsApp Cloud API support -- Message queue support (RabbitMQ, SQS) -- Real-time updates via WebSocket -- Media file handling with S3/Minio storage -- Multiple chatbot integrations with trigger management +## Important Implementation Details -### Environment Variables -Critical configuration in `.env`: -- SERVER_TYPE, SERVER_PORT, SERVER_URL -- DATABASE_PROVIDER and DATABASE_CONNECTION_URI -- Log levels and Baileys-specific logging -- Instance lifecycle management (DEL_INSTANCE) -- Feature toggles for data persistence (DATABASE_SAVE_*) \ No newline at end of file +### WhatsApp Instance Management +- Each WhatsApp connection is an "instance" with unique name +- Instance data stored in database with connection state +- Session persistence in database or file system (configurable) +- Automatic reconnection handling with exponential backoff + +### Message Queue Architecture +- Supports RabbitMQ, Amazon SQS, and WebSocket for events +- Event types: message.received, message.sent, connection.update, etc. +- Configurable per instance which events to send + +### Media Handling +- Local storage or S3/Minio for media files +- Automatic media download from WhatsApp +- Media URL generation for external access +- Support for audio transcription via OpenAI + +### Multi-tenancy Support +- Instance isolation at database level +- Separate webhook configurations per instance +- Independent integration settings per instance + +## Environment Configuration + +Key environment variables are defined in `.env.example`. The system uses a strongly-typed configuration system via `src/config/env.config.ts`. + +Critical configurations: +- `DATABASE_PROVIDER`: postgresql or mysql +- `DATABASE_CONNECTION_URI`: Database connection string +- `AUTHENTICATION_API_KEY`: Global API authentication +- `REDIS_ENABLED`: Enable Redis cache +- `RABBITMQ_ENABLED`/`SQS_ENABLED`: Message queue options + +## Development Guidelines from Cursor Instructions + +The project includes specific development instructions in `.cursor/instructions`: +- Always respond in Portuguese Brazilian +- Follow established architecture patterns +- Robust error handling with retry logic +- Multi-database compatibility requirements +- Security validations and rate limiting +- Performance optimizations with caching +- Minimum 70% test coverage target + +## Testing Approach + +Tests are located alongside source files or in dedicated test directories. The project uses: +- Unit tests for services +- Integration tests for critical APIs +- Mock external dependencies +- Test command runs with watch mode for development + +## Deployment Considerations + +- Docker support with `Dockerfile` and `docker-compose.yaml` +- Graceful shutdown handling for connections +- Health check endpoints for monitoring +- Sentry integration for error tracking +- Telemetry for usage analytics (non-sensitive data only) \ No newline at end of file