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.

245 lines
7.8 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 type { Database } from 'bun:sqlite';
import { CommandService } from './services/command';
import { GroupSyncService } from './services/group-sync';
import { ResponseQueue } from './services/response-queue';
import { TaskService } from './tasks/service';
import { WebhookManager } from './services/webhook-manager';
import { normalizeWhatsAppId } from './utils/whatsapp';
import { ensureUserExists, db, initializeDatabase } 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
// });
// Normalize event name to handle different casing/format (e.g., MESSAGES_UPSERT)
const evt = String(payload.event);
const evtNorm = evt.toLowerCase().replace(/_/g, '.');
switch (evtNorm) {
case 'messages.upsert':
if (process.env.NODE_ENV !== 'test') {
console.log(' Handling message upsert:', {
groupId: payload.data?.key?.remoteJid,
message: payload.data?.message?.conversation,
rawEvent: evt
});
}
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')) {
// Extraer menciones desde el mensaje
const mentions = data.message?.contextInfo?.mentionedJid || [];
// Asegurar que CommandService y TaskService usen la misma DB (tests/producción)
(CommandService as any).dbInstance = WebhookServer.dbInstance;
(TaskService as any).dbInstance = WebhookServer.dbInstance;
// Delegar el manejo del comando
const responses = await CommandService.handle({
sender: normalizedSenderId,
groupId: data.key.remoteJid,
message: messageText,
mentions
});
// Encolar respuestas si las hay
if (responses.length > 0) {
await ResponseQueue.add(responses);
}
}
}
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();
// Ensure database schema and migrations are applied
initializeDatabase(this.dbInstance);
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;
}
}