primer commit

This commit is contained in:
2026-01-17 21:43:47 -06:00
commit c8450ed5a8
40 changed files with 11170 additions and 0 deletions

7
gateway/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
*.log
.env.local
.env.*.local
auth_info/*.json
!auth_info/.gitkeep

18
gateway/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:18-alpine
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
# Build
COPY . .
RUN npm run build
# Expose ports
EXPOSE 3001
EXPOSE 3003
# Command
CMD ["npm", "start"]

5118
gateway/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
gateway/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "whatsapp-gateway",
"version": "1.0.0",
"description": "Professional WhatsApp Gateway with Baileys",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@whiskeysockets/baileys": "^7.0.0-rc.9",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"pino": "^8.16.2",
"pino-pretty": "^10.2.3",
"qrcode": "^1.5.3",
"ws": "^8.14.2"
},
"devDependencies": {
"@types/cors": "^2.8.15",
"@types/express": "^4.17.20",
"@types/node": "^20.8.7",
"@types/qrcode": "^1.5.5",
"@types/ws": "^8.5.8",
"@typescript-eslint/eslint-plugin": "^6.9.0",
"@typescript-eslint/parser": "^6.9.0",
"eslint": "^8.52.0",
"tsx": "^4.1.4",
"typescript": "^5.2.2"
}
}

108
gateway/src/api/messages.ts Normal file
View File

@@ -0,0 +1,108 @@
import { Request, Response } from 'express';
import { logger } from '../config/logger';
import { ApiResponse } from './send';
interface MessageData {
id: string;
from: string;
to: string;
content: string;
timestamp: string;
type: 'text' | 'image' | 'document';
}
interface RecentMessages {
messages: MessageData[];
total: number;
}
export class MessagesController {
private recentMessages: MessageData[] = [];
private maxMessages = 50;
constructor() {
// Setup message storage
}
addMessage = (messageData: MessageData): void => {
this.recentMessages.unshift(messageData);
if (this.recentMessages.length > this.maxMessages) {
this.recentMessages = this.recentMessages.slice(0, this.maxMessages);
}
logger.info(`Message added to recent messages: ${messageData.id}`);
};
getRecentMessages = async (req: Request, res: Response): Promise<void> => {
try {
const limit = parseInt(req.query.limit as string) || 10;
const response: ApiResponse<RecentMessages> = {
success: true,
data: {
messages: this.recentMessages.slice(0, limit),
total: this.recentMessages.length
},
timestamp: new Date().toISOString()
};
res.json(response);
} catch (error) {
const response: ApiResponse = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
};
logger.error(`Get recent messages failed: ${error}`);
res.status(500).json(response);
}
};
clearMessages = async (req: Request, res: Response): Promise<void> => {
try {
this.recentMessages = [];
const response: ApiResponse = {
success: true,
data: { message: 'Messages cleared successfully' },
timestamp: new Date().toISOString()
};
logger.info('Recent messages cleared');
res.json(response);
} catch (error) {
const response: ApiResponse = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
};
logger.error(`Clear messages failed: ${error}`);
res.status(500).json(response);
}
};
getMessageCount = (): number => {
return this.recentMessages.length;
};
// Método para ser llamado desde el Gateway cuando llega un mensaje
handleMessage = (message: any): void => {
try {
const messageData: MessageData = {
id: message.key.id || Date.now().toString(),
from: message.key.remoteJid || 'unknown',
to: 'me',
content: message.message?.conversation || message.message?.extendedTextMessage?.text || '(No text content)',
timestamp: new Date().toISOString(),
type: message.message?.conversation ? 'text' : 'document'
};
this.addMessage(messageData);
} catch (error) {
logger.error(`Failed to process message: ${error}`);
}
};
}

228
gateway/src/api/n8n.ts Normal file
View File

@@ -0,0 +1,228 @@
import { Request, Response } from 'express';
import { logger } from '../config/logger';
import { AuthMiddleware } from '../middleware/auth';
interface n8nSendMessageRequest {
to: string;
message: string;
}
interface n8nMessageResponse {
success: boolean;
messageId?: string;
to: string;
message: string;
timestamp: string;
error?: string;
}
export class n8nController {
private whatsappClient: any;
constructor(whatsappClient: any) {
this.whatsappClient = whatsappClient;
}
validatePhoneNumber(phone: string): { valid: boolean; formatted: string } {
// Allow Group JIDs
if (phone.endsWith('@g.us')) {
return { valid: true, formatted: phone };
}
// Remove non-numeric characters
let cleaned = phone.replace(/[^\d]/g, '');
// Basic validation for different formats
if (cleaned.length < 10) {
return { valid: false, formatted: phone };
}
// Ensure country code (if starting with 0, remove it and add country code if needed)
if (cleaned.startsWith('0')) {
cleaned = cleaned.substring(1);
}
// Add country code if not present (assuming El Salvador by default: 503)
if (cleaned.length === 8 && !cleaned.startsWith('503')) {
cleaned = '503' + cleaned;
}
// Validate reasonable length
if (cleaned.length < 10 || cleaned.length > 15) {
return { valid: false, formatted: phone };
}
return { valid: true, formatted: cleaned + '@s.whatsapp.net' };
}
getGroups = async (req: Request, res: Response): Promise<void> => {
try {
const groups = await this.whatsappClient.getGroups();
const simplifiedGroups = groups.map((g: any) => ({
id: g.id,
subject: g.subject,
description: g.desc,
participants: g.participants?.length || 0,
creation: g.creation
}));
res.json({
success: true,
count: simplifiedGroups.length,
groups: simplifiedGroups,
timestamp: new Date().toISOString()
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
});
}
};
sendMessage = async (req: Request, res: Response): Promise<void> => {
try {
const { to, message }: n8nSendMessageRequest = req.body;
if (!to || !message) {
const response: n8nMessageResponse = {
success: false,
to: to || '(missing)',
message: message || '(missing)',
timestamp: new Date().toISOString(),
error: 'Both "to" and "message" fields are required'
};
res.status(400).json(response);
return;
}
// Validate phone number
const phoneValidation = this.validatePhoneNumber(to);
if (!phoneValidation.valid) {
const response: n8nMessageResponse = {
success: false,
to,
message: message.substring(0, 50) + (message.length > 50 ? '...' : ''),
timestamp: new Date().toISOString(),
error: `Invalid phone number format: ${to}`
};
res.status(400).json(response);
return;
}
// Check WhatsApp connection
const connectionState = this.whatsappClient.getConnectionState();
if (connectionState !== 'connected') {
const response: n8nMessageResponse = {
success: false,
to,
message: message.substring(0, 50) + (message.length > 50 ? '...' : ''),
timestamp: new Date().toISOString(),
error: 'WhatsApp not connected. Please scan QR code first.'
};
res.status(503).json(response);
return;
}
// Send message
const result = await this.whatsappClient.sendMessage(phoneValidation.formatted, message, 'text');
const response: n8nMessageResponse = {
success: true,
messageId: result.key?.id || 'unknown',
to: to,
message: message,
timestamp: new Date().toISOString()
};
logger.info(`n8n message sent to ${to}: ${message.substring(0, 50)}...`);
res.json(response);
} catch (error) {
const response: n8nMessageResponse = {
success: false,
to: req.body.to || '(unknown)',
message: req.body.message || '(unknown)',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error'
};
logger.error(`n8n send message failed: ${error}`);
res.status(500).json(response);
}
};
getStatus = async (req: Request, res: Response): Promise<void> => {
try {
const connectionState = this.whatsappClient.getConnectionState();
const sessionId = this.whatsappClient.sessionStore?.['sessionId'] || 'default';
const response = {
success: true,
status: connectionState,
sessionId,
uptime: process.uptime(),
timestamp: new Date().toISOString(),
capabilities: {
send: connectionState === 'connected',
receive: true,
webhook: true
}
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
});
}
};
generateToken = async (req: Request, res: Response): Promise<void> => {
try {
const permissions = ['send', 'status', 'messages'];
const token = AuthMiddleware.generateToken(permissions);
const response = {
success: true,
token: token,
permissions: permissions,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
timestamp: new Date().toISOString()
};
logger.info('n8n token generated');
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
});
}
};
validateToken = async (req: Request, res: Response): Promise<void> => {
try {
const token = (req as any).token;
const tokenInfo = AuthMiddleware.getTokenInfo(token);
res.json({
success: true,
token: token,
valid: tokenInfo ? !tokenInfo.expired : false,
info: tokenInfo,
timestamp: new Date().toISOString()
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
});
}
};
}

110
gateway/src/api/send.ts Normal file
View File

@@ -0,0 +1,110 @@
import { Request, Response } from 'express';
import { logger } from '../config/logger';
export interface SendMessageRequest {
jid: string;
content: string;
type?: 'text' | 'image' | 'document';
}
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
timestamp: string;
}
export class SendController {
private whatsappClient: any;
constructor(whatsappClient: any) {
this.whatsappClient = whatsappClient;
}
sendMessage = async (req: Request, res: Response): Promise<void> => {
try {
const { jid, content, type = 'text' }: SendMessageRequest = req.body;
if (!jid || !content) {
const response: ApiResponse = {
success: false,
error: 'jid and content are required',
timestamp: new Date().toISOString()
};
res.status(400).json(response);
return;
}
const result = await this.whatsappClient.sendMessage(jid, content, type);
const response: ApiResponse = {
success: true,
data: { messageId: result.key.id, status: 'sent' },
timestamp: new Date().toISOString()
};
logger.info(`Message sent to ${jid}`);
res.json(response);
} catch (error) {
const response: ApiResponse = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
};
logger.error(`Send message failed: ${error}`);
res.status(500).json(response);
}
};
sendBulk = async (req: Request, res: Response): Promise<void> => {
try {
const { messages }: { messages: SendMessageRequest[] } = req.body;
if (!Array.isArray(messages) || messages.length === 0) {
const response: ApiResponse = {
success: false,
error: 'messages array is required',
timestamp: new Date().toISOString()
};
res.status(400).json(response);
return;
}
const results = await Promise.allSettled(
messages.map(msg => this.whatsappClient.sendMessage(msg.jid, msg.content, msg.type))
);
const successful = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
const response: ApiResponse = {
success: true,
data: {
total: messages.length,
successful,
failed,
results: results.map((r, i) => ({
index: i,
status: r.status,
data: r.status === 'fulfilled' ? r.value.key.id : null,
error: r.status === 'rejected' ? r.reason : null
}))
},
timestamp: new Date().toISOString()
};
logger.info(`Bulk send completed: ${successful}/${messages.length} successful`);
res.json(response);
} catch (error) {
const response: ApiResponse = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
};
logger.error(`Bulk send failed: ${error}`);
res.status(500).json(response);
}
};
}

106
gateway/src/api/session.ts Normal file
View File

@@ -0,0 +1,106 @@
import { Request, Response } from 'express';
import { logger } from '../config/logger';
import { ApiResponse } from './send';
export class SessionController {
private whatsappClient: any;
constructor(whatsappClient: any) {
this.whatsappClient = whatsappClient;
}
restartSession = async (req: Request, res: Response): Promise<void> => {
try {
logger.info('Restarting WhatsApp session');
// Disconnect current session
await this.whatsappClient.disconnect();
// Delete session credentials
this.whatsappClient.sessionStore?.delete();
// Reconnect after a short delay
setTimeout(async () => {
try {
await this.whatsappClient.connect();
logger.info('Session restarted successfully');
} catch (error) {
logger.error(`Failed to restart session: ${error}`);
}
}, 1000);
const response: ApiResponse = {
success: true,
data: { message: 'Session restart initiated' },
timestamp: new Date().toISOString()
};
res.json(response);
} catch (error) {
const response: ApiResponse = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
};
logger.error(`Session restart failed: ${error}`);
res.status(500).json(response);
}
};
logoutSession = async (req: Request, res: Response): Promise<void> => {
try {
logger.info('Logging out WhatsApp session');
await this.whatsappClient.disconnect();
this.whatsappClient.sessionStore?.delete();
const response: ApiResponse = {
success: true,
data: { message: 'Session logged out successfully' },
timestamp: new Date().toISOString()
};
res.json(response);
} catch (error) {
const response: ApiResponse = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
};
logger.error(`Session logout failed: ${error}`);
res.status(500).json(response);
}
};
generateToken = async (req: Request, res: Response): Promise<void> => {
try {
const token = Buffer.from(`${Date.now()}-${Math.random().toString(36).substring(2)}`).toString('base64');
const tokenData = {
token,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 hours
permissions: ['send', 'bulk', 'status', 'messages']
};
const response: ApiResponse = {
success: true,
data: tokenData,
timestamp: new Date().toISOString()
};
logger.info('API token generated');
res.json(response);
} catch (error) {
const response: ApiResponse = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
};
logger.error(`Token generation failed: ${error}`);
res.status(500).json(response);
}
};
}

60
gateway/src/api/status.ts Normal file
View File

@@ -0,0 +1,60 @@
import { Request, Response } from 'express';
import { logger } from '../config/logger';
import { ApiResponse } from './send';
export class StatusController {
private whatsappClient: any;
constructor(whatsappClient: any) {
this.whatsappClient = whatsappClient;
}
getStatus = async (req: Request, res: Response): Promise<void> => {
try {
const connectionState = this.whatsappClient.getConnectionState();
const sessionId = this.whatsappClient.sessionStore?.['sessionId'] || 'default';
const response: ApiResponse = {
success: true,
data: {
status: connectionState,
sessionId,
uptime: process.uptime(),
memory: process.memoryUsage(),
timestamp: new Date().toISOString()
},
timestamp: new Date().toISOString()
};
res.json(response);
} catch (error) {
const response: ApiResponse = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
};
logger.error(`Get status failed: ${error}`);
res.status(500).json(response);
}
};
getHealth = async (req: Request, res: Response): Promise<void> => {
try {
const isHealthy = this.whatsappClient.getConnectionState() === 'connected';
res.status(isHealthy ? 200 : 503).json({
healthy: isHealthy,
status: isHealthy ? 'ok' : 'degraded',
timestamp: new Date().toISOString()
});
} catch (error) {
res.status(503).json({
healthy: false,
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
});
}
};
}

151
gateway/src/api/webhook.ts Normal file
View File

@@ -0,0 +1,151 @@
import { Request, Response } from 'express';
import { logger } from '../config/logger';
interface WebhookMessage {
from: string;
body: string;
timestamp: string;
type: 'text' | 'image' | 'document';
messageId: string;
}
interface WebhookResponse {
success: boolean;
received: boolean;
timestamp: string;
error?: string;
}
export class WebhookController {
private webhookUrl?: string;
private enabled: boolean = false;
constructor(webhookUrl?: string) {
this.webhookUrl = webhookUrl;
this.enabled = !!webhookUrl;
}
configureWebhook = async (req: Request, res: Response): Promise<void> => {
try {
const { url, enabled = true } = req.body;
if (url) {
this.webhookUrl = url;
this.enabled = enabled;
// Test webhook
const testPayload = {
event: 'webhook_configured',
timestamp: new Date().toISOString(),
message: 'Webhook configured successfully'
};
await this.sendWebhook(testPayload);
}
const response = {
success: true,
webhookUrl: this.webhookUrl,
enabled: this.enabled,
timestamp: new Date().toISOString()
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
});
}
};
receiveMessage = async (messageData: any): Promise<void> => {
if (!this.enabled || !this.webhookUrl) {
logger.info('Webhook not configured, skipping message forwarding');
return;
}
try {
const webhookMessage: WebhookMessage = {
from: messageData.from || 'unknown',
body: messageData.content || messageData.message || '',
timestamp: messageData.timestamp || new Date().toISOString(),
type: messageData.type || 'text',
messageId: messageData.id || Date.now().toString()
};
await this.sendWebhook(webhookMessage);
logger.info(`Message forwarded to webhook: ${webhookMessage.from}`);
} catch (error) {
logger.error(`Failed to forward message to webhook: ${error}`);
}
};
private async sendWebhook(payload: any): Promise<void> {
const response = await fetch(this.webhookUrl!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'WhatsApp-Gateway-Webhook/1.0'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Webhook failed: ${response.status} ${response.statusText}`);
}
}
testWebhook = async (req: Request, res: Response): Promise<void> => {
try {
if (!this.enabled || !this.webhookUrl) {
res.status(400).json({
success: false,
error: 'Webhook not configured',
timestamp: new Date().toISOString()
});
return;
}
const testPayload = {
event: 'test',
from: '50312345678@s.whatsapp.net',
body: 'Test message from WhatsApp Gateway',
timestamp: new Date().toISOString(),
type: 'text',
messageId: 'test-' + Date.now()
};
await this.sendWebhook(testPayload);
const response: WebhookResponse = {
success: true,
received: true,
timestamp: new Date().toISOString()
};
res.json(response);
} catch (error) {
const response: WebhookResponse = {
success: false,
received: false,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error'
};
res.status(500).json(response);
}
};
getWebhookStatus = async (req: Request, res: Response): Promise<void> => {
const response = {
success: true,
configured: this.enabled,
webhookUrl: this.webhookUrl || null,
timestamp: new Date().toISOString()
};
res.json(response);
};
}

View File

@@ -0,0 +1,13 @@
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
},
});

293
gateway/src/index.ts Normal file
View File

@@ -0,0 +1,293 @@
import express from 'express';
//import cors from 'cors';
import dotenv from 'dotenv';
import { WhatsAppClient } from './whatsapp/client';
import { QRSocketServer } from './sockets/qr.socket';
import { SendController } from './api/send';
import { StatusController } from './api/status';
import { SessionController } from './api/session';
import { MessagesController } from './api/messages';
import { n8nController } from './api/n8n';
import { WebhookController } from './api/webhook';
import { AuthMiddleware } from './middleware/auth';
import { logger } from './config/logger';
dotenv.config();
class WhatsAppGateway {
private app: express.Application;
private whatsappClient!: WhatsAppClient;
private socketServer!: QRSocketServer;
private sendController!: SendController;
private statusController!: StatusController;
private sessionController!: SessionController;
private messagesController!: MessagesController;
private n8nController!: n8nController;
private webhookController!: WebhookController;
constructor() {
this.app = express();
this.setupMiddleware();
this.initializeComponents();
this.setupRoutes();
}
private setupMiddleware(): void {
// Configure CORS for n8n and Manager
const corsOrigins = [
...(process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:3002']),
'http://localhost:3004', // Add 3004 for current setup
'http://localhost:5678', // n8n default
'http://localhost:5679', // n8n alternative
...(process.env.N8N_ORIGINS ? process.env.N8N_ORIGINS.split(',') : [])
];
// ⚠️ SOLUCIÓN BRUTA — SOLO DEV
this.app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Intercepta preflight
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
/* this.app.use(cors({
origin: corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));*/
this.app.use(express.json({ limit: '10mb' }));
this.app.use(express.urlencoded({ extended: true }));
}
private initializeComponents(): void {
const sessionId = process.env.SESSION_ID || 'default';
this.whatsappClient = new WhatsAppClient(sessionId);
this.socketServer = new QRSocketServer(3003);
this.sendController = new SendController(this.whatsappClient);
this.statusController = new StatusController(this.whatsappClient);
this.sessionController = new SessionController(this.whatsappClient);
this.messagesController = new MessagesController();
this.n8nController = new n8nController(this.whatsappClient);
this.webhookController = new WebhookController(process.env.WEBHOOK_URL);
this.setupWhatsAppEvents();
this.connectWhatsApp();
this.setupWebSocketCommands();
// Send current state when new clients connect
this.socketServer.onClientConnect = () => {
const currentState = this.whatsappClient.getConnectionState();
this.socketServer.sendStatus(currentState);
};
}
private setupWebSocketCommands(): void {
// Handle WebSocket commands from Manager
this.socketServer.onCommand = async (command: string, data?: any) => {
logger.info(`Received WebSocket command: ${command}`);
switch (command) {
case 'restart_session':
await this.sessionController.restartSession({} as any, { json: () => {} } as any);
break;
case 'logout_session':
await this.sessionController.logoutSession({} as any, { json: () => {} } as any);
break;
case 'generate_token':
const tokenResponse = await this.sessionController.generateToken({} as any, { json: (data: any) => {
this.socketServer.broadcast({
type: 'token_generated',
data: JSON.stringify(data)
});
} } as any);
break;
case 'get_recent_messages':
await this.messagesController.getRecentMessages({} as any, { json: (data: any) => {
this.socketServer.broadcast({
type: 'status',
data: JSON.stringify(data)
});
} } as any);
break;
default:
logger.warn(`Unknown WebSocket command: ${command}`);
}
};
}
private setupWhatsAppEvents(): void {
this.whatsappClient.onQR((qr: string) => {
this.socketServer.sendQR(qr);
});
this.whatsappClient.onStatus((status: string) => {
this.socketServer.sendStatus(status);
logger.info(`WhatsApp status: ${status}`);
});
this.whatsappClient.onMessage(async (message: any) => {
try {
logger.info('Message received via WhatsApp');
if (!message || !message.key) {
logger.warn('Invalid message structure received');
return;
}
// Store in recent messages
this.messagesController.handleMessage(message);
// Forward to webhook
await this.webhookController.receiveMessage({
id: message.key.id || Date.now().toString(),
from: message.key.remoteJid || 'unknown',
content: message.message?.conversation || message.message?.extendedTextMessage?.text || '',
type: message.message?.conversation ? 'text' : 'document',
timestamp: new Date().toISOString()
});
// Notify Manager
this.socketServer.broadcast({
type: 'status',
data: 'message_received'
});
} catch (error) {
logger.error(`Failed to process message: ${error}`);
}
});
}
private async connectWhatsApp(): Promise<void> {
try {
await this.whatsappClient.connect();
logger.info('WhatsApp client initialized');
} catch (error) {
logger.error(`Failed to initialize WhatsApp client: ${error}`);
}
}
private setupRoutes(): void {
// Legacy API Routes (Manager)
this.app.post('/api/send', this.sendController.sendMessage);
this.app.post('/api/send/bulk', this.sendController.sendBulk);
this.app.get('/api/status', this.statusController.getStatus);
this.app.get('/api/health', this.statusController.getHealth);
// Session management (Manager)
this.app.post('/api/session/restart', this.sessionController.restartSession);
this.app.post('/api/session/logout', this.sessionController.logoutSession);
this.app.post('/api/token', this.sessionController.generateToken);
// Messages (Manager)
this.app.get('/api/messages', this.messagesController.getRecentMessages);
this.app.delete('/api/messages', this.messagesController.clearMessages);
// n8n API Core (Component 3)
this.app.post('/api/messages/send', AuthMiddleware.middleware('send'), this.n8nController.sendMessage);
this.app.get('/api/groups', AuthMiddleware.middleware('messages'), this.n8nController.getGroups);
this.app.get('/api/status', AuthMiddleware.middleware('status'), this.n8nController.getStatus);
this.app.post('/api/n8n/token', AuthMiddleware.middleware('status'), this.n8nController.validateToken);
// Public n8n token generation (no auth required)
this.app.post('/api/n8n/generate-token', this.n8nController.generateToken);
// Webhooks for n8n (receiving messages)
this.app.post('/webhook/whatsapp', (req, res) => {
// This endpoint is for n8n to receive messages FROM WhatsApp
res.json({ success: true, message: 'Webhook endpoint active' });
});
// Webhook configuration
this.app.post('/api/webhook/configure', AuthMiddleware.middleware('messages'), this.webhookController.configureWebhook);
this.app.get('/api/webhook/status', AuthMiddleware.middleware('messages'), this.webhookController.getWebhookStatus);
this.app.post('/api/webhook/test', AuthMiddleware.middleware('messages'), this.webhookController.testWebhook);
// Root endpoint
this.app.get('/', (req, res) => {
res.json({
name: 'WhatsApp Gateway',
version: '1.0.0',
status: 'running',
endpoints: {
// Manager endpoints
send: 'POST /api/send',
sendBulk: 'POST /api/send/bulk',
status: 'GET /api/status',
health: 'GET /api/health',
sessionRestart: 'POST /api/session/restart',
sessionLogout: 'POST /api/session/logout',
token: 'POST /api/token',
messages: 'GET /api/messages',
websocket: 'ws://localhost:3003',
// n8n API Core endpoints
n8nSend: 'POST /api/messages/send (Bearer auth required)',
n8nStatus: 'GET /api/status (Bearer auth required)',
n8nToken: 'POST /api/n8n/generate-token',
webhook: 'POST /webhook/whatsapp',
webhookConfig: 'POST /api/webhook/configure',
webhookTest: 'POST /api/webhook/test'
},
authentication: {
type: 'Bearer Token',
header: 'Authorization: Bearer <token>',
generate: 'POST /api/n8n/generate-token'
}
});
});
// Error handling
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error(`Unhandled error: ${err}`);
res.status(500).json({
success: false,
error: 'Internal server error',
timestamp: new Date().toISOString()
});
});
}
async start(): Promise<void> {
const port = parseInt(process.env.PORT || '3001');
this.app.listen(port, () => {
logger.info(`WhatsApp Gateway API running on port ${port}`);
logger.info(`WebSocket server running on port 3003`);
logger.info(`Manager Web should connect to: http://localhost:3002`);
});
}
async stop(): Promise<void> {
await this.whatsappClient.disconnect();
this.socketServer.close();
logger.info('WhatsApp Gateway stopped');
}
}
// Start the gateway
const gateway = new WhatsAppGateway();
gateway.start().catch(error => {
logger.error(`Failed to start gateway: ${error}`);
process.exit(1);
});
// Graceful shutdown
process.on('SIGINT', async () => {
logger.info('Received SIGINT, shutting down gracefully...');
await gateway.stop();
process.exit(0);
});
process.on('SIGTERM', async () => {
logger.info('Received SIGTERM, shutting down gracefully...');
await gateway.stop();
process.exit(0);
});

View File

@@ -0,0 +1,130 @@
import { Request, Response, NextFunction } from 'express';
import { logger } from '../config/logger';
interface TokenStore {
[token: string]: {
createdAt: string;
expiresAt: string;
permissions: string[];
};
}
// In-memory token store (en producción usar Redis/DB)
const tokenStore: TokenStore = {};
export class AuthMiddleware {
static generateToken(permissions: string[] = ['send', 'status', 'messages']): string {
const token = Buffer.from(`${Date.now()}-${Math.random().toString(36).substring(2)}`).toString('base64');
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // 24 horas
tokenStore[token] = {
createdAt: new Date().toISOString(),
expiresAt,
permissions
};
logger.info(`Token generated: ${token.substring(0, 20)}...`);
return token;
}
static validateToken(token: string): { valid: boolean; permissions?: string[] } {
const tokenData = tokenStore[token];
if (!tokenData) {
logger.warn(`Invalid token: ${token.substring(0, 20)}...`);
return { valid: false };
}
if (new Date() > new Date(tokenData.expiresAt)) {
logger.warn(`Expired token: ${token.substring(0, 20)}...`);
delete tokenStore[token];
return { valid: false };
}
return { valid: true, permissions: tokenData.permissions };
}
static revokeToken(token: string): boolean {
if (tokenStore[token]) {
delete tokenStore[token];
logger.info(`Token revoked: ${token.substring(0, 20)}...`);
return true;
}
return false;
}
static getTokenInfo(token: string): any {
const tokenData = tokenStore[token];
if (!tokenData) return null;
return {
...tokenData,
expired: new Date() > new Date(tokenData.expiresAt)
};
}
static listTokens(): Array<{ token: string; info: any }> {
return Object.entries(tokenStore).map(([token, info]) => ({
token: token.substring(0, 20) + '...',
info
}));
}
static middleware(requiredPermission?: string) {
return (req: Request, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
success: false,
error: 'Authorization header required',
timestamp: new Date().toISOString()
});
}
// Bearer token format: "Bearer xxxxxx"
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return res.status(401).json({
success: false,
error: 'Invalid authorization format. Expected: Bearer <token>',
timestamp: new Date().toISOString()
});
}
const token = parts[1];
const validation = AuthMiddleware.validateToken(token);
if (!validation.valid) {
return res.status(401).json({
success: false,
error: 'Invalid or expired token',
timestamp: new Date().toISOString()
});
}
if (requiredPermission && !validation.permissions?.includes(requiredPermission)) {
return res.status(403).json({
success: false,
error: `Insufficient permissions. Required: ${requiredPermission}`,
timestamp: new Date().toISOString()
});
}
// Add token info to request
(req as any).token = token;
(req as any).permissions = validation.permissions;
next();
} catch (error) {
logger.error(`Auth middleware error: ${error}`);
return res.status(500).json({
success: false,
error: 'Authentication error',
timestamp: new Date().toISOString()
});
}
};
}
}

View File

@@ -0,0 +1,115 @@
import { WebSocketServer } from 'ws';
import * as WebSocket from 'ws';
import { logger } from '../config/logger';
export interface QRSocketMessage {
type: 'qr' | 'status' | 'error' | 'token_generated';
data: string;
}
export class QRSocketServer {
private wss: WebSocketServer;
private clients: Set<WebSocket> = new Set();
public onCommand?: (command: string, data?: any) => void;
public onClientConnect?: () => void;
constructor(port: number) {
this.wss = new WebSocketServer({ port });
this.setupServer();
}
private setupServer(): void {
this.wss.on('connection', (ws: WebSocket) => {
logger.info('WebSocket client connected');
this.clients.add(ws);
ws.on('close', () => {
logger.info('WebSocket client disconnected');
this.clients.delete(ws);
});
ws.on('error', (error) => {
logger.error(`WebSocket error: ${error}`);
this.clients.delete(ws);
});
ws.on('message', (data: WebSocket.Data) => {
try {
const message = JSON.parse(data.toString());
if (message.type === 'command' && this.onCommand) {
this.onCommand(message.action, message.data);
}
} catch (error) {
logger.error(`Failed to parse WebSocket command: ${error}`);
}
});
// Send initial status
// Note: We send 'waiting' as default, the actual state will be sent by WhatsApp client events
this.sendToClient(ws, {
type: 'status',
data: 'waiting'
});
// Notify that a new client connected
if (this.onClientConnect) {
this.onClientConnect();
}
});
logger.info(`WebSocket server running on port ${this.wss.options.port}`);
}
broadcast(message: QRSocketMessage): void {
const messageStr = JSON.stringify(message);
this.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
try {
client.send(messageStr);
} catch (error) {
logger.error(`Failed to send message to client: ${error}`);
this.clients.delete(client);
}
}
});
}
private sendToClient(client: WebSocket, message: QRSocketMessage): void {
if (client.readyState === WebSocket.OPEN) {
try {
client.send(JSON.stringify(message));
} catch (error) {
logger.error(`Failed to send message to client: ${error}`);
}
}
}
sendQR(qr: string): void {
logger.info(`Broadcasting QR code to ${this.clients.size} connected clients`);
console.log('QR DATA LENGTH:', qr.length);
this.broadcast({
type: 'qr',
data: qr
});
}
sendStatus(status: string): void {
this.broadcast({
type: 'status',
data: status
});
}
sendError(error: string): void {
this.broadcast({
type: 'error',
data: error
});
}
close(): void {
this.wss.close();
logger.info('WebSocket server closed');
}
}

View File

@@ -0,0 +1,184 @@
import { makeWASocket, DisconnectReason, useMultiFileAuthState, AuthenticationState } from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import { SessionStore, SessionData } from './session.store';
import { logger } from '../config/logger';
import { join } from 'path';
export class WhatsAppClient {
private client: any;
public sessionStore: SessionStore;
private qrCallback?: (qr: string) => void;
private statusCallback?: (status: string) => void;
private messageCallback?: (message: any) => void;
private saveCreds?: () => Promise<void>;
private isConnected: boolean = false;
constructor(sessionId: string) {
this.sessionStore = new SessionStore(sessionId);
}
async connect(): Promise<void> {
try {
this.isConnected = false;
const sessionId = this.sessionStore['sessionId'] || 'default';
const authPath = join(process.cwd(), 'auth_info', sessionId);
// Use proper Baileys authentication
const { state, saveCreds } = await useMultiFileAuthState(authPath);
const hasCredentials = state.creds?.registered;
if (!hasCredentials) {
logger.info('No credentials found, initiating new session - QR will be generated');
this.statusCallback?.('waiting');
} else {
logger.info('Existing credentials found, attempting to connect');
this.statusCallback?.('connecting');
}
this.client = makeWASocket({
auth: state,
printQRInTerminal: false,
logger: logger.child({ module: 'baileys' }),
browser: ['WhatsApp Gateway', 'Chrome', '120.0.0'],
mobile: false,
syncFullHistory: false,
markOnlineOnConnect: false,
generateHighQualityLinkPreview: false,
});
// Store saveCreds for later use
this.saveCreds = saveCreds;
this.setupEventHandlers();
} catch (error) {
logger.error(`Failed to connect: ${error}`);
this.statusCallback?.('error');
throw error;
}
}
private setupEventHandlers(): void {
this.client.ev.on('connection.update', (update: any) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
logger.info('QR code received, broadcasting to clients');
console.log('QR CODE GENERATED - Length:', qr.length);
if (this.qrCallback) {
this.qrCallback(qr);
}
if (this.statusCallback) {
this.statusCallback('qr');
}
}
if (connection === 'close') {
this.isConnected = false;
const shouldReconnect = (lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut;
logger.info(`Connection closed. Reconnect: ${shouldReconnect}`);
if (shouldReconnect) {
this.statusCallback?.('reconnecting');
setTimeout(() => this.connect(), 5000);
} else {
this.statusCallback?.('logout');
}
}
if (connection === 'open') {
logger.info('WhatsApp connection opened');
this.isConnected = true;
this.statusCallback?.('connected');
}
if (connection === 'connecting') {
logger.info('WhatsApp connecting...');
this.statusCallback?.('connecting');
}
});
this.client.ev.on('creds.update', () => {
logger.info('Credentials updated');
if (this.saveCreds) {
this.saveCreds();
}
});
this.client.ev.on('messages.upsert', (event: any) => {
const { messages } = event;
if (messages && messages.length > 0) {
messages.forEach((msg: any) => {
logger.info('Message received');
this.messageCallback?.(msg);
});
}
});
}
onQR(callback: (qr: string) => void): void {
this.qrCallback = callback;
}
onStatus(callback: (status: string) => void): void {
this.statusCallback = callback;
}
onMessage(callback: (message: any) => void): void {
this.messageCallback = callback;
}
async sendMessage(jid: string, content: string, type: 'text' | 'image' | 'document' = 'text'): Promise<any> {
try {
const message = type === 'text'
? { text: content }
: { caption: content };
const result = await this.client.sendMessage(jid, message);
logger.info(`Message sent to ${jid}, result: ${JSON.stringify(result)}`);
// Ensure we always return a valid result structure
if (!result) {
return {
key: { id: `unknown-${Date.now()}` },
status: 'sent'
};
}
return result;
} catch (error) {
logger.error(`Failed to send message: ${error}`);
throw error;
}
}
async getGroups(): Promise<any[]> {
try {
if (!this.client) {
throw new Error('WhatsApp client not initialized');
}
const groups = await this.client.groupFetchAllParticipating();
return Object.values(groups);
} catch (error) {
logger.error(`Failed to fetch groups: ${error}`);
throw error;
}
}
getConnectionState(): string {
return this.isConnected ? 'connected' : 'disconnected';
}
async disconnect(): Promise<void> {
try {
this.isConnected = false;
if (this.client) {
this.client.ws?.close();
this.client.end?.();
}
logger.info('WhatsApp client disconnected');
} catch (error) {
logger.error(`Failed to disconnect: ${error}`);
}
}
}

View File

@@ -0,0 +1,35 @@
import { existsSync, rmSync } from 'fs';
import { join } from 'path';
import { logger } from '../config/logger';
export interface SessionData {
id: string;
creds?: any;
keys?: any;
}
export class SessionStore {
private sessionId: string;
constructor(sessionId: string) {
this.sessionId = sessionId;
}
// Baileys now handles session persistence automatically
// We only need this for session metadata
getAuthPath(): string {
return join(process.cwd(), 'auth_info', this.sessionId);
}
delete(): void {
try {
const authPath = this.getAuthPath();
if (existsSync(authPath)) {
rmSync(authPath, { recursive: true, force: true });
logger.info(`Session deleted for ${this.sessionId}`);
}
} catch (error) {
logger.error(`Failed to delete session: ${error}`);
}
}
}

19
gateway/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}