You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

296 lines
9.2 KiB
TypeScript

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/// <reference types="bun-types" />
import { CommandService } from './services/command';
import { GroupSyncService } from './services/group-sync';
import { ResponseQueue } from './services/response-queue';
import { WebhookManager } from './services/webhook-manager';
import { normalizeWhatsAppId } from './utils/whatsapp';
import { ensureUserExists, db } from './db';
// Bun is available globally when running under Bun runtime
declare global {
var Bun: typeof import('bun');
}
export const REQUIRED_ENV = [
'EVOLUTION_API_URL',
'EVOLUTION_API_KEY',
'EVOLUTION_API_INSTANCE',
'CHATBOT_PHONE_NUMBER',
'WEBHOOK_URL'
];
type WebhookPayload = {
event: string;
instance: string;
data: any;
// Other fields from Evolution API
};
export class WebhookServer {
static dbInstance: Database = db;
private static getBaseUrl(request: Request): string {
const proto = request.headers.get('x-forwarded-proto') || 'http';
const host = request.headers.get('x-forwarded-host') || request.headers.get('host');
return `${proto}://${host}`;
}
static async handleRequest(request: Request): Promise<Response> {
// Health check endpoint
const url = new URL(request.url);
if (url.pathname.endsWith('/health')) {
return new Response('OK', { status: 200 });
}
if (process.env.NODE_ENV !== 'test') {
console.log(' Incoming webhook request:')
}
// 1. Method validation
if (request.method !== 'POST') {
return new Response('🚫 Method not allowed', { status: 405 });
}
// 2. Content-Type validation
const contentType = request.headers.get('content-type');
if (!contentType?.includes('application/json')) {
return new Response('🚫 Invalid content type', { status: 400 });
}
try {
// 3. Parse and validate payload
const payload = await request.json() as WebhookPayload;
if (!payload.event || !payload.instance) {
return new Response('🚫 Invalid payload', { status: 400 });
}
// 4. Verify instance matches (skip in test environment unless TEST_VERIFY_INSTANCE is set)
if ((process.env.NODE_ENV !== 'test' || process.env.TEST_VERIFY_INSTANCE) &&
payload.instance !== process.env.EVOLUTION_API_INSTANCE) {
return new Response('🚫 Invalid instance', { status: 403 });
}
// 5. Route events
// console.log(' Webhook event received:', {
// event: payload.event,
// instance: payload.instance,
// data: payload.data ? '[...]' : null
// });
switch (payload.event) {
case 'messages.upsert':
if (process.env.NODE_ENV !== 'test') {
console.log(' Handling message upsert:', {
groupId: payload.data?.key?.remoteJid,
message: payload.data?.message?.conversation
});
}
await WebhookServer.handleMessageUpsert(payload.data);
break;
// Other events will be added later
}
return new Response('OK', { status: 200 });
} catch (error) {
console.error('❌ Error processing webhook:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
time: new Date().toISOString()
});
return new Response('Invalid request', { status: 400 });
}
}
static async handleMessageUpsert(data: any) {
if (!data?.key?.remoteJid || !data.message || !data.message.conversation) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Invalid message format - missing required fields');
console.log(data);
}
return;
}
// Normalize sender ID for consistency and validation
const normalizedSenderId = normalizeWhatsAppId(data.key.participant);
if (!normalizedSenderId) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Invalid sender ID, ignoring message');
}
return;
}
// Ensure user exists in database
const userId = ensureUserExists(data.key.participant, WebhookServer.dbInstance);
if (!userId) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Failed to ensure user exists, ignoring message');
}
return;
}
// Check if the group is active
if (!GroupSyncService.isGroupActive(data.key.remoteJid)) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Group is not active, ignoring message');
}
return;
}
// Forward to command service only if:
// 1. It's a text message (has conversation field)
// 2. Starts with /tarea command
const messageText = data.message.conversation;
if (typeof messageText === 'string' && messageText.trim().startsWith('/tarea')) {
// Parse command components
const commandParts = messageText.trim().split(/\s+/);
const command = commandParts[0].toLowerCase();
if (commandParts.length < 2) {
console.log('⚠️ Invalid /tarea command - missing action');
return;
}
const action = commandParts[1].toLowerCase();
let descriptionParts = [];
let dueDate = '';
// Process remaining parts
const dateCandidates = [];
// First collect all valid future dates
for (let i = 2; i < commandParts.length; i++) {
const part = commandParts[i];
// Check for date (YYYY-MM-DD)
if (/^\d{4}-\d{2}-\d{2}$/.test(part)) {
const date = new Date(part);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (date >= today) {
dateCandidates.push({ index: i, date, part });
}
}
}
// Use the last valid future date as due date
if (dateCandidates.length > 0) {
const lastDate = dateCandidates[dateCandidates.length - 1];
dueDate = lastDate.part;
// Add all parts except the last date to description
for (let i = 2; i < commandParts.length; i++) {
// Skip the due date part
if (i === lastDate.index) continue;
// Add to description if it's a date candidate but not selected or a regular part
const part = commandParts[i];
descriptionParts.push(part);
}
} else {
// No valid future dates, add all parts to description
descriptionParts = commandParts.slice(2);
}
const description = descriptionParts.join(' ').trim();
console.log('🔍 Detected /tarea command:', {
timestamp: new Date().toISOString(),
from: data.key.participant,
group: data.key.remoteJid,
rawMessage: messageText
});
const mentions = data.message?.contextInfo?.mentionedJid || [];
console.log('✅ Successfully parsed command:', {
action,
description,
dueDate: dueDate || 'none',
mentionCount: mentions.length
});
// Implement command processing logic here for real contact
let responseMessage = 'Comando procesado';
if (action === 'nueva') {
responseMessage = `Tarea creada: ${description}${dueDate ? ` (fecha límite: ${dueDate})` : ''}`;
} else {
responseMessage = `Acción ${action} no implementada aún`;
}
// Queue response for sending
await ResponseQueue.add([{
recipient: normalizedSenderId,
message: responseMessage
}]);
}
}
static validateEnv() {
console.log(' Checking environment variables...');
console.log('EVOLUTION_API_URL:', process.env.EVOLUTION_API_URL ? '***' : 'MISSING');
console.log('EVOLUTION_API_INSTANCE:', process.env.EVOLUTION_API_INSTANCE || 'MISSING');
console.log('WEBHOOK_URL:', process.env.WEBHOOK_URL ? `${process.env.WEBHOOK_URL.substring(0, 20)}...` : 'NOT SET');
console.log('WHATSAPP_COMMUNITY_ID:', process.env.WHATSAPP_COMMUNITY_ID ? '***' : 'NOT SET (se mostrarán comunidades disponibles)');
const missing = REQUIRED_ENV.filter(v => !process.env[v]);
if (missing.length) {
console.error('❌ Missing required environment variables:');
missing.forEach(v => console.error(`- ${v}`));
console.error('Add these to your CapRover environment configuration');
process.exit(1);
}
if (process.env.CHATBOT_PHONE_NUMBER &&
!/^\d+$/.test(process.env.CHATBOT_PHONE_NUMBER)) {
console.error('❌ CHATBOT_PHONE_NUMBER must contain only digits');
process.exit(1);
}
}
static async start() {
this.validateEnv();
const PORT = process.env.PORT || '3007';
console.log('✅ Environment variables validated');
if (process.env.NODE_ENV !== 'test') {
try {
await WebhookManager.registerWebhook();
// Add small delay to allow webhook to propagate
await new Promise(resolve => setTimeout(resolve, 1000));
const isActive = await WebhookManager.verifyWebhook();
if (!isActive) {
console.error('❌ Webhook verification failed - retrying in 2 seconds...');
await new Promise(resolve => setTimeout(resolve, 2000));
const isActiveRetry = await WebhookManager.verifyWebhook();
if (!isActiveRetry) {
console.error('❌ Webhook verification failed after retry');
process.exit(1);
}
}
// Initialize groups - critical for operation
await GroupSyncService.checkInitialGroups();
// Start response queue worker (background)
try {
await ResponseQueue.process();
console.log('✅ ResponseQueue worker started');
} catch (e) {
console.error('❌ Failed to start ResponseQueue worker:', e);
}
} catch (error) {
console.error('❌ Failed to setup webhook:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
const server = Bun.serve({
port: parseInt(PORT),
fetch: (request) => WebhookServer.handleRequest(request)
});
console.log(`Server running on port ${PORT}`);
return server;
}
}